import logging from typing import Any, Callable, Type from django.db.models import Exists, OuterRef from django.utils.translation import gettext as _ from labs.models import ( ExamSection as DBExamSection, ExamQuestion as DBExamQuestion, ExamAttemptSection as DBExamAttemptSection, ExamAttemptAnswer as DBExamAttemptAnswer, ) from labs.exams.exceptions import ( ExamSectionHandlerCapabilityError, ExamQuestionNotFoundError, ExamAnswerValidationError, ) logger = logging.getLogger(__name__) SectionHandlerFactory = Callable[[DBExamAttemptSection], "ExamSectionHandler"] SectionHandlerRegistry = dict[DBExamSection.SectionType, Type["ExamSectionHandler"]] _SECTION_HANDLER_REGISTRY: SectionHandlerRegistry = {} def register_section_handler( section_type: DBExamSection.SectionType, ) -> Callable[[Type["ExamSectionHandler"]], Type["ExamSectionHandler"]]: def decorator(cls: Type["ExamSectionHandler"]) -> Type["ExamSectionHandler"]: _SECTION_HANDLER_REGISTRY[section_type] = cls return cls return decorator class ExamSectionHandler: def __init__(self, db_exam_attempt_section: DBExamAttemptSection): self.db_exam_attempt_section = db_exam_attempt_section def avaliable(self) -> bool: return ( self.db_exam_attempt_section.status == DBExamAttemptSection.Status.AVAILABLE ) def ensure_capability(self, capability: str) -> Callable: if not hasattr(self, capability): raise ExamSectionHandlerCapabilityError( f"Section handler does not have capability: {capability}" ) return getattr(self, capability) def can_submit(self) -> tuple[bool, str]: # check if the section can be submitted # return a boolean and a string as the reason raise NotImplementedError def calculate_score(self) -> tuple[int, list[dict]]: # calculate score for the section # should be called after the section is submitted # return the score and result detail of the section raise NotImplementedError def detail(self) -> dict: return { "id": self.db_exam_attempt_section.section_id, "title": self.db_exam_attempt_section.section.title, "section_type": self.db_exam_attempt_section.section.section_type, "display_order": self.db_exam_attempt_section.display_order, "status": self.db_exam_attempt_section.status, "content": None, } @register_section_handler(DBExamSection.SectionType.INSTRUCTION) class InstructionSectionHandler(ExamSectionHandler): def can_submit(self) -> tuple[bool, str]: return True, "" def calculate_score(self) -> tuple[int, list[dict]]: return 0, [] def detail(self) -> dict: data = super().detail() if self.db_exam_attempt_section.status != DBExamAttemptSection.Status.LOCKED: data["content"] = self.db_exam_attempt_section.section.content return data @register_section_handler(DBExamSection.SectionType.QUESTION) class QuestionSectionHandler(ExamSectionHandler): def can_submit(self) -> tuple[bool, str]: return True, "" def get_frontend_questions(self) -> list[dict]: return list( DBExamQuestion.objects.filter(section=self.db_exam_attempt_section.section) .order_by("display_order") .annotate( is_answered=Exists( DBExamAttemptAnswer.objects.filter( question=OuterRef("pk"), attempt_section=self.db_exam_attempt_section, is_answered=True, ) ) ) .values("id", "display_order", "question_type", "is_answered") ) def get_frontend_question(self, question_id: int) -> dict: try: question = DBExamQuestion.objects.get( id=question_id, section=self.db_exam_attempt_section.section, ) except DBExamQuestion.DoesNotExist: raise ExamQuestionNotFoundError() try: user_answer = DBExamAttemptAnswer.objects.get( question=question, attempt_section=self.db_exam_attempt_section, ).answer except DBExamAttemptAnswer.DoesNotExist: user_answer = None return { "id": question.id, "question_type": question.question_type, "stem": question.stem, "content": question.content, "user_answer": user_answer, } def _validate_answer(self, question: DBExamQuestion, answer: Any) -> list: # 1) must be a list (allow empty to represent "clear answer") if not isinstance(answer, list): raise ExamAnswerValidationError(_("Answer must be a list.")) # 2) each item must be a hashable scalar (int/str); explicitly reject bool for item in answer: if isinstance(item, bool) or not isinstance(item, (int, str)): raise ExamAnswerValidationError( _("Answer items must be integers or strings.") ) # 3) no duplicates (keeps storage canonical and prevents ambiguity) if len(answer) != len(set(answer)): raise ExamAnswerValidationError(_("Duplicate answers are not allowed.")) # 4) validate against option IDs from question.content content = question.content or [] if not isinstance(content, list): raise ExamAnswerValidationError(_("Question options are invalid.")) option_ids: set[Any] = set() for opt in content: if isinstance(opt, dict) and "id" in opt: option_ids.add(opt["id"]) if not option_ids: # misconfigured question (should not happen under normal admin flow) raise ExamAnswerValidationError(_("Question options are invalid.")) for ans_id in answer: if ans_id not in option_ids: raise ExamAnswerValidationError( _("Invalid option ID: %(id)s") % {"id": ans_id} ) # 5) per question type rules if question.question_type == DBExamQuestion.QuestionType.SINGLE_CHOICE: if len(answer) > 1: raise ExamAnswerValidationError( _("Single choice question allows at most 1 answer.") ) return answer def submit_answer(self, question_id: int, answer: Any) -> None: try: question = DBExamQuestion.objects.get( id=question_id, section=self.db_exam_attempt_section.section, ) except DBExamQuestion.DoesNotExist: raise ExamQuestionNotFoundError() validated_answer = self._validate_answer(question, answer) DBExamAttemptAnswer.objects.update_or_create( question=question, attempt=self.db_exam_attempt_section.attempt, attempt_section=self.db_exam_attempt_section, defaults={ "answer": validated_answer, "is_answered": len(validated_answer) > 0, }, ) def _is_correct(self, question: DBExamQuestion, user_answer: list) -> bool: correct = question.answer or [] if question.question_type == DBExamQuestion.QuestionType.SINGLE_CHOICE: return ( len(user_answer) == 1 and len(correct) == 1 and user_answer[0] == correct[0] ) return set(user_answer) == set(correct) def calculate_score(self) -> tuple[int, list[dict]]: score = 0 detail: list[dict] = [] qs = DBExamQuestion.objects.filter( section=self.db_exam_attempt_section.section ).order_by("display_order") answers = { a.question_id: a for a in DBExamAttemptAnswer.objects.filter( attempt_section=self.db_exam_attempt_section ) } for q in qs: ans = answers.get(q.id) if not ans: continue user_answer = ans.answer if ans else [] is_correct = ans.is_answered and self._is_correct(q, user_answer) gained = q.score if is_correct else 0 score += gained detail.append( { "question_id": q.id, "is_correct": is_correct, "gained_score": gained, "max_score": q.score, } ) return score, detail def get_section_handler( db_exam_attempt_section: DBExamAttemptSection, ) -> ExamSectionHandler: handler_cls = _SECTION_HANDLER_REGISTRY.get( db_exam_attempt_section.section.section_type ) if not handler_cls: raise ValueError( f"Invalid section type: {db_exam_attempt_section.section.section_type}" ) return handler_cls(db_exam_attempt_section)