From bbf54331521242ac2739e6a2bf68415540b6ca48 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Tue, 24 Dec 2024 11:45:48 +0100 Subject: [PATCH] docs: Improve docs & typing of the `Form` action class and all `Question` classes --- backend/experiment/actions/form.py | 386 ++++++++++++++++++++++++----- 1 file changed, 320 insertions(+), 66 deletions(-) diff --git a/backend/experiment/actions/form.py b/backend/experiment/actions/form.py index 0970b4e4d..5d07dd4b3 100644 --- a/backend/experiment/actions/form.py +++ b/backend/experiment/actions/form.py @@ -1,3 +1,4 @@ +from typing import Dict, List, Union, Optional, Any, Literal from django.conf import settings from django.utils.translation import gettext_lazy as _ @@ -5,30 +6,53 @@ from .base_action import BaseAction +QuestionView = Literal[ + "AUTOCOMPLETE", "BUTTON_ARRAY", "CHECKBOXES", "DROPDOWN", "RADIOS", "RANGE", "TEXT_RANGE", "ICON_RANGE", "STRING" +] + + class Question(BaseAction): - ''' Question is part of a form. - - key: description of question in results table - - view: which widget the question should use in the frontend - - explainer: optional instructions for this specific question - - question: the question text - - scoring_rule: optionally, specify a scoring rule which should be applied - - is_skippable: whether the question can be skipped - - submits: whether entering a value for the question submits the form - - style: one (string) or multiple (dict) class names to apply for styling the frontend component - ''' + """The Question action is a component that represents a single question in a form. + + Args: + key (str): Unique identifier for the question in results table + result_id (Optional[int]): Optional result ID for testing purposes + view (QuestionView): Widget type to use in frontend (default: 'STRING') + explainer (str): Additional instructions for the question + question (str): The actual question text + is_skippable (bool): Whether question can be skipped + submits (bool): Whether answering submits the form + style (Union[str, Dict]): CSS class name(s) for styling + + Example: + ```python + question = Question( + key="name", + question="What's your name?", + explainer="Please enter your full name.", + is_skippable=True, + submits=True, + style="text-center", + view="STRING", + ) + ``` + + Note: + - You can use any of the child classes to create a specific question type (e.g. TextQuestion, BooleanQuestion). \n + - The `key` should be unique within the form. \n + """ def __init__( self, - key, - result_id=None, - view='STRING', - explainer='', - question='', - is_skippable=False, - submits=False, - style=STYLE_NEUTRAL - ): - + key: str, + result_id: Optional[int] = None, + view: QuestionView = "STRING", + explainer: str = "", + question: str = "", + is_skippable: bool = False, + submits: bool = False, + style: Union[str, Dict] = STYLE_NEUTRAL, + ) -> None: self.key = key self.view = view self.explainer = explainer @@ -38,9 +62,10 @@ def __init__( self.submits = submits self.style = style - def action(self): + def action(self) -> Dict[str, Any]: if settings.TESTING and self.result_id: from result.models import Result + result = Result.objects.get(pk=self.result_id) if result and result.expected_response: self.expected_response = result.expected_response @@ -48,81 +73,269 @@ def action(self): class NumberQuestion(Question): - def __init__(self, input_type='number', min_value=0, max_value=120, **kwargs): + """A question that accepts numeric input. + + Args: + input_type (str): Type of number input (default: 'number') + min_value (int): Minimum allowed value + max_value (int): Maximum allowed value + **kwargs: Additional Question arguments + """ + + def __init__(self, input_type: str = "number", min_value: int = 0, max_value: int = 120, **kwargs) -> None: super().__init__(**kwargs) self.min_value = min_value self.max_value = max_value self.input_type = input_type - self.view = 'STRING' + self.view = "STRING" class TextQuestion(Question): - def __init__(self, input_type='text', max_length=64, **kwargs): + """A question that accepts text input. + + Args: + input_type (str): Type of text input (default: 'text') + max_length (int): Maximum character length + **kwargs: Additional Question arguments + """ + + def __init__(self, input_type: str = "text", max_length: int = 64, **kwargs) -> None: super().__init__(**kwargs) - self.max_length = max_length # the maximum length of the question's answer in characters + self.max_length = max_length # the maximum length of the question's answer in characters self.input_type = input_type - self.view = 'STRING' + self.view = "STRING" class BooleanQuestion(Question): - def __init__(self, choices=None, **kwargs): + """A yes/no question component. + + Args: + choices (Optional[Dict[str, str]]): Custom yes/no labels + **kwargs: Additional Question arguments + + Example: + ```python + question = BooleanQuestion( + key="is_student", + question="Are you a student?", + choices={ + "no": "No", # or _("No") for translation + "yes": "Yes" + }, + ) + ``` + """ + + def __init__(self, choices: Optional[Dict[str, str]] = None, **kwargs) -> None: super().__init__(**kwargs) - self.choices = choices or { - 'no': _('No'), - 'yes': _('Yes') - } - self.view = 'BUTTON_ARRAY' - self.style = {STYLE_BOOLEAN_NEGATIVE_FIRST: True, 'buttons-large-gap': True} + self.choices = choices or {"no": _("No"), "yes": _("Yes")} + self.view = "BUTTON_ARRAY" + self.style = {STYLE_BOOLEAN_NEGATIVE_FIRST: True, "buttons-large-gap": True} class ChoiceQuestion(Question): - def __init__(self, choices, min_values=1, **kwargs): + """A question with (multiple) choice options. + + Args: + choices (Dict[str, str]): Available options + min_values (int): Minimum number of selections required + **kwargs: Additional Question arguments + + Example: + ```python + question = ChoiceQuestion( + key="color", + question="What's your favorite color?", + choices={ + "red": "Red", # or _("Red") for translation + "green": "Green", + "blue": "Blue", + }, + min_values=1, + ) + ``` + + Note: + - To have multiple choices (participant can select more than one answer), set `view` to 'CHECKBOXES' + """ + + def __init__(self, choices: Dict[str, str], min_values: int = 1, **kwargs) -> None: super().__init__(**kwargs) self.choices = choices # minimal number of values to be selected, 1 or more + # TODO: enforce (raise ValueError), or math.floor it self.min_values = min_values class DropdownQuestion(Question): - def __init__(self, choices, **kwargs): + """A question with a dropdown menu. + + Args: + choices (Dict[str, str]): Available options + **kwargs: Additional Question arguments + + Example: + ```python + question = DropdownQuestion( + key="color", + question="What's your favorite color?", + choices={ + "red": "Red", # or _("Red") for translation + "green": "Green", + "blue": "Blue", + }, + ) + ``` + """ + + def __init__(self, choices: Dict[str, str], **kwargs) -> None: super().__init__(**kwargs) self.choices = choices - self.view = 'DROPDOWN' + self.view = "DROPDOWN" class AutoCompleteQuestion(Question): - def __init__(self, choices, **kwargs): + """A question with an autocomplete input. + + Args: + choices (Dict[str, str]): Available options + **kwargs: Additional Question arguments + + Example: + ```python + question = AutoCompleteQuestion( + key="color", + question="What's your favorite color?", + choices={ + "red": "Red", # or _("Red") for translation + "green": "Green", + "blue": "Blue", + }, + ) + ``` + """ + + def __init__(self, choices: Dict[str, str], **kwargs) -> None: super().__init__(**kwargs) self.choices = choices - self.view = 'AUTOCOMPLETE' + self.view = "AUTOCOMPLETE" class RadiosQuestion(Question): - def __init__(self, choices, **kwargs): + """A question with radio buttons. + + Args: + choices (Dict[str, str]): Available options + **kwargs: Additional Question arguments + + Example: + ```python + question = RadiosQuestion( + key="color", + question="What's your favorite color?", + choices={ + "red": "Red", # or _("Red") for translation + "green": "Green", + "blue": "Blue", + }, + ) + ``` + """ + + def __init__(self, choices: Dict[str, str], **kwargs) -> None: super().__init__(**kwargs) self.choices = choices - self.view = 'RADIOS' + self.view = "RADIOS" class ButtonArrayQuestion(Question): - def __init__(self, choices, **kwargs): + """A question with a button array. + + Args: + choices (Dict[str, str]): Available options + **kwargs: Additional Question arguments + + Example: + ```python + question = ButtonArrayQuestion( + key="color", + question="What's your favorite color?", + choices={ + "red": "Red", # or _("Red") for translation + "green": "Green", + "blue": "Blue", + }, + ) + ``` + """ + + def __init__(self, choices: Dict[str, str], **kwargs) -> None: super().__init__(**kwargs) self.choices = choices - self.view = 'BUTTON_ARRAY' + self.view = "BUTTON_ARRAY" class RangeQuestion(Question): - def __init__(self, min_value, max_value, **kwargs): + """A question with a range slider. + + Args: + min_value (int): Minimum value + max_value (int): Maximum value + **kwargs: Additional Question arguments + + Example: + ```python + question = RangeQuestion( + key="age", + question="How old are you?", + min_value=18, + max_value=120, + ) + ``` + """ + + def __init__(self, min_value: int, max_value: int, **kwargs) -> None: super().__init__(**kwargs) self.min_value = min_value self.max_value = max_value +LikertView = Literal["TEXT_RANGE", "ICON_RANGE"] + + class LikertQuestion(Question): - def __init__(self, scale_steps=7, explainer=_("How much do you agree or disagree?"), likert_view='TEXT_RANGE', choices = {}, **kwargs): + """A question with a Likert scale. + + Args: + scale_steps (int): Number of scale steps (default: 7) + explainer (str): Additional instructions for the question + likert_view (LikertView): Likert scale view (default: 'TEXT_RANGE') + choices (Dict[int, str]): Custom Likert scale labels + **kwargs: Additional Question arguments + + Example: + ```python + question = LikertQuestion( + key="satisfaction", + question="How satisfied are you with the service?", + explainer="Please rate your satisfaction.", + scale_steps=5, + likert_view="TEXT_RANGE", + ) + ``` + """ + + def __init__( + self, + scale_steps: int = 7, + explainer: str = _("How much do you agree or disagree?"), + likert_view: LikertView = "TEXT_RANGE", + choices: Dict[int, str] = {}, + **kwargs, + ) -> None: super().__init__(**kwargs) self.view = likert_view - self.scoring_rule = 'LIKERT' + self.scoring_rule = "LIKERT" self.scale_steps = scale_steps self.explainer = explainer @@ -151,41 +364,82 @@ def __init__(self, scale_steps=7, explainer=_("How much do you agree or disagree class LikertQuestionIcon(Question): - def __init__(self, scale_steps=7, likert_view='ICON_RANGE', **kwargs): + """A question with a Likert scale using icons. + + Args: + scale_steps (int): Number of scale steps (default: 7) + likert_view (LikertView): Likert scale view (default: 'ICON_RANGE') + **kwargs: Additional Question arguments + + Example: + ```python + question = LikertQuestionIcon( + key="satisfaction", + question="How satisfied are you with the service?", + scale_steps=5, + likert_view="ICON_RANGE", + ) + ``` + """ + + def __init__(self, scale_steps: int = 7, likert_view: LikertView = "ICON_RANGE", **kwargs) -> None: super().__init__(**kwargs) self.view = likert_view if scale_steps == 7: self.choices = { - 1: 'fa-face-grin-hearts', - 2: 'fa-face-grin', - 3: 'fa-face-smile', - 4: 'fa-face-meh', # Undecided - 5: 'fa-face-frown', - 6: 'fa-face-frown-open', - 7: 'fa-face-angry', + 1: "fa-face-grin-hearts", + 2: "fa-face-grin", + 3: "fa-face-smile", + 4: "fa-face-meh", # Undecided + 5: "fa-face-frown", + 6: "fa-face-frown-open", + 7: "fa-face-angry", } self.style = STYLE_GRADIENT_7 class Form(BaseAction): - ''' Form is a view which brings together an array of questions with submit and optional skip button - - form: array of questions - - button_label: label of submit button - - skip_label: label of skip button - - is_skippable: can this question form be skipped - ''' - - def __init__(self, form, submit_label=_('Continue'), skip_label=_('Skip'), is_skippable=False): + """The Form action is a view which brings together an array of questions with a submit and an optional skip button. + + Args: + form (List[Question]): List of question components + submit_label (str): Text for submit button + skip_label (str): Text for skip button + is_skippable (bool): Whether form can be skipped + + Example: + ```python + form = Form([ + TextQuestion( + key="name", + question="What's your name?", + explainer="Please enter your full name.", + ), + BooleanQuestion( + key="is_student", + question="Are you a student?", + ), + ]) + ``` + """ + + def __init__( + self, + form: List[Question], + submit_label: str = _("Continue"), + skip_label: str = _("Skip"), + is_skippable: bool = False, + ) -> None: self.form = form self.submit_label = submit_label self.skip_label = skip_label self.is_skippable = is_skippable - def action(self): + def action(self) -> Dict[str, Any]: serialized_form = [question.action() for question in self.form] return { - 'form': serialized_form, - 'submit_label': self.submit_label, - 'skip_label': self.skip_label, - 'is_skippable': self.is_skippable, + "form": serialized_form, + "submit_label": self.submit_label, + "skip_label": self.skip_label, + "is_skippable": self.is_skippable, }