From f09814298832630e2f7a5218cc8f6de75ca3abdc Mon Sep 17 00:00:00 2001 From: tulga-rdn Date: Mon, 3 Mar 2025 00:03:45 +0100 Subject: [PATCH 1/3] enable MCQ --- src/scwidgets/exercise/__init__.py | 3 +- .../_widget_multiplechoice_exercise.py | 245 ++++++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/scwidgets/exercise/_widget_multiplechoice_exercise.py diff --git a/src/scwidgets/exercise/__init__.py b/src/scwidgets/exercise/__init__.py index 802b032..99bce9a 100644 --- a/src/scwidgets/exercise/__init__.py +++ b/src/scwidgets/exercise/__init__.py @@ -1,5 +1,6 @@ from ._widget_code_exercise import CodeExercise from ._widget_exercise_registry import ExerciseRegistry, ExerciseWidget from ._widget_text_exercise import TextExercise +from ._widget_multiplechoice_exercise import MultipleChoiceExercise -__all__ = ["CodeExercise", "TextExercise", "ExerciseWidget", "ExerciseRegistry"] +__all__ = ["CodeExercise", "TextExercise", "MultipleChoiceExercise", "ExerciseWidget", "ExerciseRegistry"] diff --git a/src/scwidgets/exercise/_widget_multiplechoice_exercise.py b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py new file mode 100644 index 0000000..dd2ca81 --- /dev/null +++ b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py @@ -0,0 +1,245 @@ +from typing import Optional, Union, Any, Dict, List +import random + +from ipywidgets import HTML, HBox, HTMLMath, Layout, Output, SelectMultiple, RadioButtons, VBox +from .._utils import Formatter +from ..css_style import CssStyle +from ..cue import SaveCueBox, SaveResetCueButton +from ._widget_exercise_registry import ExerciseRegistry, ExerciseWidget + + +class MultipleChoiceExercise(VBox, ExerciseWidget): + """ + :param options: + Either a dict or a list. If a dict is provided, the widget will display the dictionary’s value + but save its key to the registry. + + :param key: + Unique key for the exercise. + + :param description: + A string describing the exercise that will be put into an HTML widget above the exercise. + + :param title: + A title for the exercise. If not provided, the key is used. + + :param exercise_registry: + An exercise registry that is used to register the answers to save them later. + If specified the save and load panel will appear. + + :param allow_multiple: + Whether multiple selections are allowed (default False). + + :param randomize_order: + If True, the order of options is randomized. + """ + + def __init__( + self, + options: Union[List[Any], Dict[Any, Any]], + key: Optional[str] = None, + description: Optional[str] = None, + title: Optional[str] = None, + exercise_registry: Optional[ExerciseRegistry] = None, + allow_multiple: bool = False, + randomize_order: bool = False, + *args, + **kwargs, + ): + self._description = description + if description is not None: + self._description_html = HTMLMath(self._description) + self._description_html.add_class("exercise-description") + else: + self._description_html = None + + if title is None: + if key is not None: + self._title = key + self._title_html = HTML(f"{key}") + else: + self._title = None + self._title_html = None + else: + self._title = title + self._title_html = HTML(f"{title}") + if self._title_html is not None: + self._title_html.add_class("exercise-title") + + if isinstance(options, dict): + self._options_dict = options + options_list = [(value, key) for key, value in options.items()] + elif isinstance(options, list): + self._options_dict = None + options_list = options + else: + raise ValueError("Options must be provided as a dict or a list.") + + if randomize_order: + random.shuffle(options_list) + + self._options_list = options_list + self.allow_multiple = allow_multiple + + if allow_multiple: + self._selection_widget = SelectMultiple( + options=options_list, + description="", + layout=Layout(width="auto"), + ) + else: + self._selection_widget = RadioButtons( + options=options_list, + description="", + layout=Layout(width="auto"), + ) + + if exercise_registry is None: + self._cue_selection = self._selection_widget + self._save_button = None + self._load_button = None + self._button_panel = None + else: + self._cue_selection = SaveCueBox(self._selection_widget, "value", self._selection_widget, cued=True) + self._save_button = SaveResetCueButton( + self._cue_selection, + self._on_click_save_action, + disable_on_successful_action=kwargs.pop("disable_save_button_on_successful_action", False), + disable_during_action=kwargs.pop("disable_save_button_during_action", True), + description="Save answer", + button_tooltip="Saves answer to the loaded file", + ) + self._load_button = SaveResetCueButton( + self._cue_selection, + self._on_click_load_action, + disable_on_successful_action=kwargs.pop("disable_load_button_on_successful_action", False), + disable_during_action=kwargs.pop("disable_load_button_during_action", True), + description="Load answer", + button_tooltip="Loads answer from the loaded file", + ) + self._save_button.set_cue_widgets([self._cue_selection, self._load_button]) + self._load_button.set_cue_widgets([self._cue_selection, self._save_button]) + self._button_panel = HBox([self._save_button, self._load_button], layout=Layout(justify_content="flex-end")) + + self._output = Output() + + if exercise_registry is not None: + ExerciseWidget.__init__(self, exercise_registry, key) + else: + # otherwise ExerciseWidget constructor will raise an error + ExerciseWidget.__init__(self, None, None) + + widget_children = [CssStyle()] + if self._title_html is not None: + widget_children.append(self._title_html) + if self._description_html is not None: + widget_children.append(self._description_html) + widget_children.append(self._cue_selection) + if self._button_panel is not None: + widget_children.append(self._button_panel) + widget_children.append(self._output) + + VBox.__init__(self, widget_children, *args, **kwargs) + + @property + def title(self) -> Union[str, None]: + return self._title + + @property + def description(self) -> Union[str, None]: + return self._description + + @property + def answer(self) -> dict: + if self.allow_multiple: + return {"selection": tuple(self._selection_widget.value)} + else: + return {"selection": self._selection_widget.value} + + @answer.setter + def answer(self, answer: dict): + if hasattr(self._cue_selection, "unobserve_widgets"): + self._cue_selection.unobserve_widgets() + if self._save_button is not None: + self._save_button.unobserve_widgets() + if self._load_button is not None: + self._load_button.unobserve_widgets() + + if self.allow_multiple: + val = answer.get("selection", ()) + if isinstance(val, (list, set, tuple)): + self._selection_widget.value = tuple(val) + else: + self._selection_widget.value = (val,) + else: + self._selection_widget.value = answer.get("selection") + + if hasattr(self._cue_selection, "observe_widgets"): + self._cue_selection.observe_widgets() + if self._save_button is not None: + self._save_button.observe_widgets() + if self._load_button is not None: + self._load_button.observe_widgets() + + def _on_click_save_action(self) -> bool: + self._output.clear_output(wait=True) + raised_error = False + with self._output: + try: + result = self.save() + if isinstance(result, str): + print(Formatter.color_success_message(result)) + elif isinstance(result, Exception): + raise result + else: + print(result) + except Exception as e: + print(Formatter.color_error_message("Error raised while saving file:")) + raised_error = True + raise e + return not raised_error + + def _on_click_load_action(self) -> bool: + self._output.clear_output(wait=True) + raised_error = False + with self._output: + try: + result = self.load() + if isinstance(result, str): + print(Formatter.color_success_message(result)) + elif isinstance(result, Exception): + raise result + else: + print(result) + return True + except Exception as e: + print(Formatter.color_error_message("Error raised while loading file:")) + raised_error = True + raise e + return not raised_error + + def handle_save_result(self, result: Union[str, Exception]) -> None: + self._output.clear_output(wait=True) + with self._output: + if isinstance(result, Exception): + print(Formatter.color_error_message("Error raised while saving file:")) + raise result + else: + if self._load_button is not None: + self._load_button.cued = False + if self._save_button is not None: + self._save_button.cued = False + print(Formatter.color_success_message(result)) + + def handle_load_result(self, result: Union[str, Exception]) -> None: + self._output.clear_output(wait=True) + with self._output: + if isinstance(result, Exception): + print(Formatter.color_error_message("Error raised while loading file:")) + raise result + else: + if self._load_button is not None: + self._load_button.cued = False + if self._save_button is not None: + self._save_button.cued = False + print(Formatter.color_success_message(result)) \ No newline at end of file From e47917e7234926db56d66f275d0f0d8ccb2e13e5 Mon Sep 17 00:00:00 2001 From: tulga-rdn Date: Mon, 3 Mar 2025 00:39:05 +0100 Subject: [PATCH 2/3] small fixes to finish --- .../_widget_multiplechoice_exercise.py | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/scwidgets/exercise/_widget_multiplechoice_exercise.py b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py index dd2ca81..66bfb97 100644 --- a/src/scwidgets/exercise/_widget_multiplechoice_exercise.py +++ b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py @@ -28,10 +28,10 @@ class MultipleChoiceExercise(VBox, ExerciseWidget): If specified the save and load panel will appear. :param allow_multiple: - Whether multiple selections are allowed (default False). + Whether multiple selections are allowed. :param randomize_order: - If True, the order of options is randomized. + Whether to randomize order of options. """ def __init__( @@ -150,29 +150,25 @@ def description(self) -> Union[str, None]: return self._description @property - def answer(self) -> dict: + def answer(self) -> tuple: if self.allow_multiple: - return {"selection": tuple(self._selection_widget.value)} + return tuple(self._selection_widget.value) else: - return {"selection": self._selection_widget.value} + return (self._selection_widget.value, ) @answer.setter - def answer(self, answer: dict): + def answer(self, answer) -> None: if hasattr(self._cue_selection, "unobserve_widgets"): self._cue_selection.unobserve_widgets() if self._save_button is not None: self._save_button.unobserve_widgets() if self._load_button is not None: self._load_button.unobserve_widgets() - - if self.allow_multiple: - val = answer.get("selection", ()) - if isinstance(val, (list, set, tuple)): - self._selection_widget.value = tuple(val) - else: - self._selection_widget.value = (val,) + + if len(answer) == 1: + self._selection_widget.value = answer[0] else: - self._selection_widget.value = answer.get("selection") + self._selection_widget.value = answer if hasattr(self._cue_selection, "observe_widgets"): self._cue_selection.observe_widgets() From ca9183754faa61ca89237c68b191df1ab42e5b4e Mon Sep 17 00:00:00 2001 From: tulga-rdn Date: Mon, 3 Mar 2025 01:04:52 +0100 Subject: [PATCH 3/3] add checks to MCQ --- .../_widget_multiplechoice_exercise.py | 68 ++++++++++++++----- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/src/scwidgets/exercise/_widget_multiplechoice_exercise.py b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py index 66bfb97..b05b9db 100644 --- a/src/scwidgets/exercise/_widget_multiplechoice_exercise.py +++ b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py @@ -1,7 +1,17 @@ -from typing import Optional, Union, Any, Dict, List import random +from typing import Any, Dict, List, Optional, Union + +from ipywidgets import ( + HTML, + HBox, + HTMLMath, + Layout, + Output, + RadioButtons, + SelectMultiple, + VBox, +) -from ipywidgets import HTML, HBox, HTMLMath, Layout, Output, SelectMultiple, RadioButtons, VBox from .._utils import Formatter from ..css_style import CssStyle from ..cue import SaveCueBox, SaveResetCueButton @@ -11,14 +21,15 @@ class MultipleChoiceExercise(VBox, ExerciseWidget): """ :param options: - Either a dict or a list. If a dict is provided, the widget will display the dictionary’s value - but save its key to the registry. - - :param key: + Either a dict or a list. If a dict is provided, the widget will display the + dictionary’s value but save its key to the registry. + + :param key: Unique key for the exercise. :param description: - A string describing the exercise that will be put into an HTML widget above the exercise. + A string describing the exercise that will be put into an HTML widget above + the exercise. :param title: A title for the exercise. If not provided, the key is used. @@ -27,10 +38,10 @@ class MultipleChoiceExercise(VBox, ExerciseWidget): An exercise registry that is used to register the answers to save them later. If specified the save and load panel will appear. - :param allow_multiple: + :param allow_multiple: Whether multiple selections are allowed. - :param randomize_order: + :param randomize_order: Whether to randomize order of options. """ @@ -100,26 +111,39 @@ def __init__( self._load_button = None self._button_panel = None else: - self._cue_selection = SaveCueBox(self._selection_widget, "value", self._selection_widget, cued=True) + self._cue_selection = SaveCueBox( + self._selection_widget, "value", self._selection_widget, cued=True + ) self._save_button = SaveResetCueButton( self._cue_selection, self._on_click_save_action, - disable_on_successful_action=kwargs.pop("disable_save_button_on_successful_action", False), - disable_during_action=kwargs.pop("disable_save_button_during_action", True), + disable_on_successful_action=kwargs.pop( + "disable_save_button_on_successful_action", False + ), + disable_during_action=kwargs.pop( + "disable_save_button_during_action", True + ), description="Save answer", button_tooltip="Saves answer to the loaded file", ) self._load_button = SaveResetCueButton( self._cue_selection, self._on_click_load_action, - disable_on_successful_action=kwargs.pop("disable_load_button_on_successful_action", False), - disable_during_action=kwargs.pop("disable_load_button_during_action", True), + disable_on_successful_action=kwargs.pop( + "disable_load_button_on_successful_action", False + ), + disable_during_action=kwargs.pop( + "disable_load_button_during_action", True + ), description="Load answer", button_tooltip="Loads answer from the loaded file", ) self._save_button.set_cue_widgets([self._cue_selection, self._load_button]) self._load_button.set_cue_widgets([self._cue_selection, self._save_button]) - self._button_panel = HBox([self._save_button, self._load_button], layout=Layout(justify_content="flex-end")) + self._button_panel = HBox( + [self._save_button, self._load_button], + layout=Layout(justify_content="flex-end"), + ) self._output = Output() @@ -154,7 +178,7 @@ def answer(self) -> tuple: if self.allow_multiple: return tuple(self._selection_widget.value) else: - return (self._selection_widget.value, ) + return (self._selection_widget.value,) @answer.setter def answer(self, answer) -> None: @@ -164,7 +188,7 @@ def answer(self, answer) -> None: self._save_button.unobserve_widgets() if self._load_button is not None: self._load_button.unobserve_widgets() - + if len(answer) == 1: self._selection_widget.value = answer[0] else: @@ -238,4 +262,12 @@ def handle_load_result(self, result: Union[str, Exception]) -> None: self._load_button.cued = False if self._save_button is not None: self._save_button.cued = False - print(Formatter.color_success_message(result)) \ No newline at end of file + print(Formatter.color_success_message(result)) + + def check_correct_answers(self, *correct_answers) -> bool: + if isinstance(correct_answers[0], (list, tuple, set)): + correct_answers = correct_answers[0] + if self.allow_multiple: + return sorted(self.answer) == sorted(correct_answers) + else: + return self.answer[0] == correct_answers[0]