Skip to content

Experiment actions

experiment.actions.base_action

BaseAction

Bases: object

base class for the experiment actions. Actions are used to configure components to be rendered on the frontend.

Source code in experiment/actions/base_action.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class BaseAction(object):
    """base class for the experiment actions.
    Actions are used to configure components to be rendered on the frontend.    
    """

    ID = 'BASE'
    style = None

    def __init__(self, style: FrontendStyle = None):
        self.style = style
        pass

    def action(self) -> dict:
        """The action that can be sent to the frontend

        Returns:
            action_dict (dict): Frontend component configuration        
        """

        action_dict = self.__dict__
        action_dict['view'] = self.ID

        # we may have already converted the style object to a dictionary, e.g., after copying an Action object
        if self.style is not None and type(self.style) is not dict:
            action_dict['style'] = self.style.to_dict()

        return action_dict

action()

The action that can be sent to the frontend

Returns:

Name Type Description
action_dict dict

Frontend component configuration

Source code in experiment/actions/base_action.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def action(self) -> dict:
    """The action that can be sent to the frontend

    Returns:
        action_dict (dict): Frontend component configuration        
    """

    action_dict = self.__dict__
    action_dict['view'] = self.ID

    # we may have already converted the style object to a dictionary, e.g., after copying an Action object
    if self.style is not None and type(self.style) is not dict:
        action_dict['style'] = self.style.to_dict()

    return action_dict

experiment.actions.consent

Consent

Bases: BaseAction

Provide data for a view that ask consent for using the experiment data

Parameters:

Name Type Description Default
text File

Uploaded file via an experiment’s translated content’s consent (fileField)

required
title str

The title to be displayed

'Informed consent'
confirm str

The text on the confirm button

'I agree'
deny str

The text on the deny button

'Stop'
url str

If no text is provided the url will be used to load a template (HTML or MARKDOWN) HTML: (default) Allowed tags: html, django template language MARKDOWN: Allowed tags: Markdown language

''
Note

Relates to client component: Consent.js

Source code in experiment/actions/consent.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class Consent(BaseAction):  # pylint: disable=too-few-public-methods
    """Provide data for a view that ask consent for using the experiment data

    Args:
        text: Uploaded file via an experiment's translated content's consent (fileField)
        title: The title to be displayed
        confirm: The text on the confirm button
        deny: The text on the deny button
        url:  If no text is provided the url will be used to load a template (HTML or MARKDOWN)
                    HTML: (default) Allowed tags: html, django template language
                    MARKDOWN: Allowed tags: Markdown language

    Note:
        Relates to client component: Consent.js
    """

    # default consent text, that can be used for multiple blocks
    ID = "CONSENT"

    default_text = "Lorem ipsum dolor sit amet, nec te atqui scribentur. Diam \
                molestie posidonium te sit, ea sea expetenda suscipiantur \
                contentiones, vix ex maiorum denique! Lorem ipsum dolor sit \
                amet, nec te atqui scribentur. Diam molestie posidonium te sit, \
                ea sea expetenda suscipiantur contentiones, vix ex maiorum \
                denique! Lorem ipsum dolor sit amet, nec te atqui scribentur. \
                Diam molestie posidonium te sit, ea sea expetenda suscipiantur \
                contentiones, vix ex maiorum denique! Lorem ipsum dolor sit \
                amet, nec te atqui scribentur. Diam molestie posidonium te sit, \
                ea sea expetenda suscipiantur contentiones."

    def __init__(self, text: File, title: str="Informed consent", confirm: str="I agree", deny: str="Stop", url: str="") -> dict:
        # Determine which text to use
        if text != "":
            # Uploaded consent via file field: block.consent (High priority)
            with text.open("r") as f:
                dry_text = f.read()
            render_format = get_render_format(text.url)
        elif url != "":
            # Template file via url (Low priority)
            dry_text = render_to_string(url)
            render_format = get_render_format(url)
        else:
            # use default text
            dry_text = self.default_text
            render_format = "HTML"
        # render text fot the consent component
        self.text = render_html_or_markdown(dry_text, render_format)
        self.title = title
        self.confirm = confirm
        self.deny = deny

get_render_format(url)

Detect markdown file based on file extension

Parameters:

Name Type Description Default
url str

Url of the consent file

required

Returns:

Type Description
str

File format

Source code in experiment/actions/consent.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def get_render_format(url: str) -> str:
    """
    Detect markdown file based on file extension

    Args:
        url: Url of the consent file

    Returns:
        File format

    """
    if splitext(url)[1] == ".md":
        return "MARKDOWN"
    return "HTML"

render_html_or_markdown(dry_text, render_format)

render html or markdown

Parameters:

Name Type Description Default
dry_text str

contents of a markdown or html file

required
render_format str

type of contents, either ‘HTML’ or ‘MARKDOWN’

required

Returns:

Type Description
str

Content rendered to html

Source code in experiment/actions/consent.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def render_html_or_markdown(dry_text: str, render_format: str) -> str:
    """render html or markdown

    Args:
        dry_text: contents of a markdown or html file
        render_format: type of contents, either 'HTML' or 'MARKDOWN'

    Returns:
        Content rendered to html
    """

    if render_format == "HTML":
        template = Template(dry_text)
        context = Context()
        return template.render(context)
    if render_format == "MARKDOWN":
        return formatter(dry_text, filter_name="markdown")

experiment.actions.explainer

Explainer

Bases: BaseAction

Provide data for a explainer that explains the experiment steps

Relates to client component: Explainer.js

Explainer view automatically proceeds to the following view after timer (in ms) expires. If timer=None, explainer view will proceed to the next view only after a click of a button. Intro explainers should always have timer=None (i.e. interaction with a browser is required), otherwise the browser will not autoplay the first segment.

Source code in experiment/actions/explainer.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Explainer(BaseAction):
    """
    Provide data for a explainer that explains the experiment steps

    Relates to client component: Explainer.js

    Explainer view automatically proceeds to the following view after timer (in ms) expires. If timer=None, explainer view will proceed to the next view only after a click of a button. Intro explainers should always have timer=None (i.e. interaction with a browser is required), otherwise the browser will not autoplay the first segment.
    """

    ID = "EXPLAINER"

    def __init__(self, instruction, steps, button_label="Let's go!", timer=None, step_numbers=False):
        self.instruction = instruction
        self.steps = steps
        self.button_label = button_label
        self.timer = timer
        self.step_numbers = step_numbers

    def action(self):
        """Get data for explainer action"""
        if self.step_numbers:
            serialized_steps = [step.action(index+1) for index, step in enumerate(self.steps)]
        else:
            serialized_steps = [step.action() for step in self.steps]
        return {
            'view': self.ID,
            'instruction': self.instruction,
            'button_label': self.button_label,
            'steps': serialized_steps,
            'timer': self.timer,
        }

action()

Get data for explainer action

Source code in experiment/actions/explainer.py
22
23
24
25
26
27
28
29
30
31
32
33
34
def action(self):
    """Get data for explainer action"""
    if self.step_numbers:
        serialized_steps = [step.action(index+1) for index, step in enumerate(self.steps)]
    else:
        serialized_steps = [step.action() for step in self.steps]
    return {
        'view': self.ID,
        'instruction': self.instruction,
        'button_label': self.button_label,
        'steps': serialized_steps,
        'timer': self.timer,
    }

Step

Bases: object

Source code in experiment/actions/explainer.py
37
38
39
40
41
42
43
44
45
46
47
48
class Step(object):

    def __init__(self, description, number=None):
        self.description = description
        self.number = number

    def action(self, number=None):
        """Create an explainer step, with description and optional number"""
        return {
            'number': self.number if self.number else number,
            'description': self.description
        }

action(number=None)

Create an explainer step, with description and optional number

Source code in experiment/actions/explainer.py
43
44
45
46
47
48
def action(self, number=None):
    """Create an explainer step, with description and optional number"""
    return {
        'number': self.number if self.number else number,
        'description': self.description
    }

experiment.actions.final

Final

Bases: BaseAction

Provide data for a final view

Relates to client component: Final.js

Source code in experiment/actions/final.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
class Final(BaseAction):  # pylint: disable=too-few-public-methods
    """
    Provide data for a final view

    Relates to client component: Final.js
    """

    ID = 'FINAL'

    RANKS = {
        'PLASTIC': {'text': _('plastic'), 'class': 'plastic'},
        'BRONZE':  {'text': _('bronze'), 'class': 'bronze'},
        'SILVER': {'text': _('silver'), 'class': 'silver'},
        'GOLD': {'text': _('gold'), 'class': 'gold'},
        'PLATINUM': {'text': _('platinum'), 'class': 'platinum'},
        'DIAMOND': {'text': _('diamond'), 'class': 'diamond'}
    }

    def __init__(
        self,
        session: Session,
        title: str = _("Final score"),
        final_text: str = None,
        button: dict = None,
        points: str = None,
        rank: str = None,
        show_profile_link: bool = False,
        show_participant_link: bool = False,
        show_participant_id_only: bool = False,
        feedback_info: dict = None,
        total_score: float = None,
        logo: dict = None,
    ):

        self.session = session
        self.title = title
        self.final_text = final_text
        self.button = button
        self.rank = rank
        self.show_profile_link = show_profile_link
        self.show_participant_link = show_participant_link
        self.show_participant_id_only = show_participant_id_only
        self.feedback_info = feedback_info
        self.logo = logo
        if total_score is None:
            self.total_score = self.session.total_score()
        else:
            self.total_score = total_score
        if points is None:
            self.points = _("points")
        else:
            self.points = points

    def action(self):
        """Get data for final action"""
        return {
            "view": self.ID,
            "score": self.total_score,
            "rank": self.rank,
            "final_text": self.final_text,
            "button": self.button,
            "points": self.points,
            "action_texts": {
                "play_again": _("Play again"),
                "profile": _("My profile"),
                "all_experiments": _("All experiments"),
            },
            "title": self.title,
            "social": self.get_social_media_config(self.session),
            "show_profile_link": self.show_profile_link,
            "show_participant_link": self.show_participant_link,
            "feedback_info": self.feedback_info,
            "participant_id_only": self.show_participant_id_only,
            "logo": self.logo,
        }

    def get_social_media_config(self, session: Session) -> dict:
        experiment = session.block.phase.experiment
        if (
            hasattr(experiment, "social_media_config")
            and experiment.social_media_config
        ):
            return serialize_social_media_config(
                experiment.social_media_config, session.total_score()
            )

action()

Get data for final action

Source code in experiment/actions/final.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def action(self):
    """Get data for final action"""
    return {
        "view": self.ID,
        "score": self.total_score,
        "rank": self.rank,
        "final_text": self.final_text,
        "button": self.button,
        "points": self.points,
        "action_texts": {
            "play_again": _("Play again"),
            "profile": _("My profile"),
            "all_experiments": _("All experiments"),
        },
        "title": self.title,
        "social": self.get_social_media_config(self.session),
        "show_profile_link": self.show_profile_link,
        "show_participant_link": self.show_participant_link,
        "feedback_info": self.feedback_info,
        "participant_id_only": self.show_participant_id_only,
        "logo": self.logo,
    }

experiment.actions.form

Form

Bases: 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

Source code in experiment/actions/form.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
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):
        self.form = form
        self.submit_label = submit_label
        self.skip_label = skip_label
        self.is_skippable = is_skippable

    def action(self):
        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,
        }

Question

Bases: 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

Source code in experiment/actions/form.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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
    '''

    def __init__(
        self,
        key,
        result_id=None,
        view='STRING',
        explainer='',
        question='',
        is_skippable=False,
        submits=False,
        style=STYLE_NEUTRAL
        ):

        self.key = key
        self.view = view
        self.explainer = explainer
        self.question = question
        self.result_id = result_id
        self.is_skippable = is_skippable
        self.submits = submits
        self.style = style

    def action(self):
        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
        return self.__dict__

experiment.actions.frontend_style

FrontendStyle

Source code in experiment/actions/frontend_style.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class FrontendStyle:

    VALID_STYLES = EFrontendStyle.__members__.values()

    """
    Initialize the FrontendStyle with a root style.
    :param root_style: The style name for the root element.
    """
    def __init__(self, root_style: EFrontendStyle = EFrontendStyle.EMPTY):

        if not EFrontendStyle.is_valid(root_style):
            raise ValueError(f"Invalid root style: {root_style}")

        self.styles = {'root': root_style}

    def get_style(self, element: str) -> str:
        """
        Get the style for a specific element.
        :param element: The element identifier for which to get the style.
        :return: The style name for the given element.
        """
        return self.styles.get(element, None)

    def apply_style(self, element: str, style: str) -> None:
        """
        Apply a specific style to an element after validating the style.
        :param element: The element identifier to apply the style to.
        :param style: The style name to apply.
        """
        if EFrontendStyle.is_valid(style):
            self.styles[element] = style
        else:
            valid_styles = ', '.join([str(s) for s in self.VALID_STYLES])
            raise ValueError(f"Invalid style: {style}. Valid styles are {valid_styles}.")

    def to_dict(self) -> dict:
        serialized_styles = { 'root': self.styles['root'].value }

        return serialized_styles

    def __str__(self):
        return str(self.to_dict())

    def __json__(self):
        return self.to_dict()

VALID_STYLES = EFrontendStyle.__members__.values() class-attribute instance-attribute

Initialize the FrontendStyle with a root style. :param root_style: The style name for the root element.

apply_style(element, style)

Apply a specific style to an element after validating the style. :param element: The element identifier to apply the style to. :param style: The style name to apply.

Source code in experiment/actions/frontend_style.py
45
46
47
48
49
50
51
52
53
54
55
def apply_style(self, element: str, style: str) -> None:
    """
    Apply a specific style to an element after validating the style.
    :param element: The element identifier to apply the style to.
    :param style: The style name to apply.
    """
    if EFrontendStyle.is_valid(style):
        self.styles[element] = style
    else:
        valid_styles = ', '.join([str(s) for s in self.VALID_STYLES])
        raise ValueError(f"Invalid style: {style}. Valid styles are {valid_styles}.")

get_style(element)

Get the style for a specific element. :param element: The element identifier for which to get the style. :return: The style name for the given element.

Source code in experiment/actions/frontend_style.py
37
38
39
40
41
42
43
def get_style(self, element: str) -> str:
    """
    Get the style for a specific element.
    :param element: The element identifier for which to get the style.
    :return: The style name for the given element.
    """
    return self.styles.get(element, None)

experiment.actions.html

HTML

Bases: BaseAction

A custom view that handles a custom HTML question Relates to client component: HTML.js

Source code in experiment/actions/html.py
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class HTML(BaseAction):  # pylint: disable=too-few-public-methods
    """
    A custom view that handles a custom HTML question
    Relates to client component: HTML.js    
    """

    ID = 'HTML'

    def __init__(self, body):
        """
        - body: HTML body
        """
        self.body = body

__init__(body)

  • body: HTML body
Source code in experiment/actions/html.py
13
14
15
16
17
def __init__(self, body):
    """
    - body: HTML body
    """
    self.body = body

experiment.actions.info

Info

Bases: BaseAction

Provide data for a view that shows information (HTML)

Relates to client component: Info.js

Source code in experiment/actions/info.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Info(BaseAction):  # pylint: disable=too-few-public-methods
    """
    Provide data for a view that shows information (HTML)

    Relates to client component: Info.js    
    """

    ID = "INFO"

    def __init__(self, body, heading="", button_label=None, button_link=None):
        """
        Info shows an formatted information page with an HTML body
        body: html body
        heading: title/heading on top
        button_label: label of button on bottom
        button_link: (optional) button link. If no link is set, 'onNext' is called
        """

        self.body = body
        self.heading = heading
        self.button_label = button_label
        self.button_link = button_link

__init__(body, heading='', button_label=None, button_link=None)

Info shows an formatted information page with an HTML body body: html body heading: title/heading on top button_label: label of button on bottom button_link: (optional) button link. If no link is set, ‘onNext’ is called

Source code in experiment/actions/info.py
13
14
15
16
17
18
19
20
21
22
23
24
25
def __init__(self, body, heading="", button_label=None, button_link=None):
    """
    Info shows an formatted information page with an HTML body
    body: html body
    heading: title/heading on top
    button_label: label of button on bottom
    button_link: (optional) button link. If no link is set, 'onNext' is called
    """

    self.body = body
    self.heading = heading
    self.button_label = button_label
    self.button_link = button_link

experiment.actions.playback

Autoplay

Bases: Playback

This player starts playing automatically - show_animation: if True, show a countdown and moving histogram

Source code in experiment/actions/playback.py
61
62
63
64
65
66
67
68
69
class Autoplay(Playback):
    '''
    This player starts playing automatically
    - show_animation: if True, show a countdown and moving histogram
    '''

    def __init__(self, sections, **kwargs):
        super().__init__(sections, **kwargs)
        self.ID = TYPE_AUTOPLAY

ImagePlayer

Bases: Multiplayer

This is a special case of the Multiplayer: it shows an image next to each play button

Source code in experiment/actions/playback.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
class ImagePlayer(Multiplayer):
    '''
    This is a special case of the Multiplayer:
    it shows an image next to each play button
    '''

    def __init__(self, sections, images, image_labels=[], **kwargs):
        super().__init__(sections, **kwargs)
        self.ID = TYPE_IMAGE
        if len(images) != len(self.sections):
            raise UserWarning(
                'Number of images and sections for the ImagePlayer do not match')
        self.images = images
        if image_labels:
            if len(image_labels) != len(self.sections):
                raise UserWarning(
                    'Number of image labels and sections do not match')
            self.image_labels = image_labels

MatchingPairs

Bases: Multiplayer

This is a special case of multiplayer: play buttons are represented as cards - sections: a list of sections (in many cases, will only contain one section) - score_feedback_display: how to display the score feedback (large-top, small-bottom-right, hidden)

Source code in experiment/actions/playback.py
130
131
132
133
134
135
136
137
138
139
140
141
class MatchingPairs(Multiplayer):
    '''
    This is a special case of multiplayer:
    play buttons are represented as cards
    - sections: a list of sections (in many cases, will only contain *one* section)
    - score_feedback_display: how to display the score feedback (large-top, small-bottom-right, hidden)
    '''

    def __init__(self, sections: List[Dict], score_feedback_display: str = 'large-top', **kwargs):
        super().__init__(sections, **kwargs)
        self.ID = TYPE_MATCHINGPAIRS
        self.score_feedback_display = score_feedback_display

Multiplayer

Bases: PlayButton

This is a player with multiple play buttons - stop_audio_after: after how many seconds to stop audio - labels: pass list of strings if players should have custom labels

Source code in experiment/actions/playback.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
class Multiplayer(PlayButton):
    '''
    This is a player with multiple play buttons
    - stop_audio_after: after how many seconds to stop audio
    - labels: pass list of strings if players should have custom labels
    '''

    def __init__(
        self,
        sections,
        stop_audio_after=5,
        labels=[],
        style=FrontendStyle(),
        **kwargs,
    ):
        super().__init__(sections, **kwargs)
        self.ID = TYPE_MULTIPLAYER
        self.stop_audio_after = stop_audio_after
        self.style = style
        if labels:
            if len(labels) != len(self.sections):
                raise UserWarning(
                    'Number of labels and sections for the play buttons do not match')
            self.labels = labels

PlayButton

Bases: Playback

This player shows a button, which triggers playback - play_once: if True, button will be disabled after one play

Source code in experiment/actions/playback.py
72
73
74
75
76
77
78
79
80
81
class PlayButton(Playback):
    '''
    This player shows a button, which triggers playback
    - play_once: if True, button will be disabled after one play
    '''

    def __init__(self, sections, play_once=False, **kwargs):
        super().__init__(sections, **kwargs)
        self.ID = TYPE_BUTTON
        self.play_once = play_once

Playback

Bases: BaseAction

A playback base class for different kinds of players - sections: a list of sections (in many cases, will only contain one section) - preload_message: text to display during preload - instruction: text to display during presentation of the sound - play_from: where in the audio file to start playing/ - show_animation: whether to show animations with this player - mute: whether to mute the audio - timeout_after_playback: once playback has finished, add optional timeout (in seconds) before proceeding - stop_audio_after: stop playback after so many seconds - resume_play: if the playback should resume from where a previous view left off

Source code in experiment/actions/playback.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class Playback(BaseAction):
    ''' A playback base class for different kinds of players
        - sections: a list of sections (in many cases, will only contain *one* section)
        - preload_message: text to display during preload
        - instruction: text to display during presentation of the sound
        - play_from: where in the audio file to start playing/
        - show_animation: whether to show animations with this player
        - mute: whether to mute the audio
        - timeout_after_playback: once playback has finished, add optional timeout (in seconds) before proceeding
        - stop_audio_after: stop playback after so many seconds
        - resume_play: if the playback should resume from where a previous view left off
    '''

    def __init__(
        self,
        sections,
        preload_message='',
        instruction='',
        play_from=0,
        show_animation=False,
        mute=False,
        timeout_after_playback=None,
        stop_audio_after=None,
        resume_play=False,
        style=FrontendStyle()
    ):
        self.sections = [{'id': s.id, 'url': s.absolute_url(), 'group': s.group}
                         for s in sections]
        self.play_method = determine_play_method(sections[0])
        self.show_animation = show_animation
        self.preload_message = preload_message
        self.instruction = instruction
        self.play_from = play_from
        self.mute = mute
        self.timeout_after_playback = timeout_after_playback
        self.stop_audio_after = stop_audio_after
        self.resume_play = resume_play
        self.style = style

experiment.actions.redirect

experiment.actions.score

Score

Bases: BaseAction

Provide data for an intermediate score view

Relates to client component: Score.js

Source code in experiment/actions/score.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class Score(BaseAction):  # pylint: disable=too-few-public-methods
    """
    Provide data for an intermediate score view

    Relates to client component: Score.js
    """

    ID = 'SCORE'

    def __init__(self, session, title: str = None, score=None, score_message=None, config=None, icon=None, timer=None, feedback=None):
        """ Score presents feedback to a participant after a Trial
        - session: a Session object
        - title: the title of the score page
        - score_message: a function which constructs feedback text based on the score
        - config: a dict with the following settings:
            - show_section: whether metadata of the previous section should be shown
            - show_total_score: whether the total score should be shown
        - icon: the name of a themify-icon shown with the view or None
        - timer: int or None. If int, wait for as many seconds until showing the next view
        - feedback: An additional feedback text
        """
        self.session = session
        self.title = title or _('Round {get_rounds_passed} / {total_rounds}').format(
            get_rounds_passed=session.get_rounds_passed(),
            total_rounds=self.session.block.rounds
        )
        self.score = score or session.last_score()
        self.score_message = score_message or self.default_score_message
        self.feedback = feedback
        self.config = {
            'show_section': False,
            'show_total_score': False
        }
        if config:
            self.config.update(config)
        self.icon = icon
        self.texts = {
            'score': _('Total Score'),
            'next': _('Next'),
            'listen_explainer': _('You listened to:')
        }
        self.timer = timer

    def action(self):
        """Serialize score data"""
        # Create action
        action = {
            'view': self.ID,
            'title': self.title,
            'score': self.score,
            'score_message': self.score_message(self.score),
            'texts': self.texts,
            'feedback': self.feedback,
            'icon': self.icon,
            'timer': self.timer
        }
        if self.config['show_section']:
            action['last_song'] = self.session.last_song()
        if self.config['show_total_score']:
            action['total_score'] = self.session.total_score()
        return action

    def default_score_message(self, score):
        """Fallback to generate a message for the given score"""

        # None
        if score is None:
            score = 0
        # Zero
        if score == 0:
            # "Too bad!", "Come on!", "Try another!", "Try again!"
            return random.choice([_("No points")])
        # Negative
        if score < 0:
            return random.choice([_("Incorrect")])  # "Too bad!", "Fail!", "Nooo!"
        # Positive
        # "Well done!", "Nice job!", "Great one!", "Score!", "You're good!", "Awesome!", "Nice one!"
        return random.choice([_("Correct")])

__init__(session, title=None, score=None, score_message=None, config=None, icon=None, timer=None, feedback=None)

Score presents feedback to a participant after a Trial - session: a Session object - title: the title of the score page - score_message: a function which constructs feedback text based on the score - config: a dict with the following settings: - show_section: whether metadata of the previous section should be shown - show_total_score: whether the total score should be shown - icon: the name of a themify-icon shown with the view or None - timer: int or None. If int, wait for as many seconds until showing the next view - feedback: An additional feedback text

Source code in experiment/actions/score.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def __init__(self, session, title: str = None, score=None, score_message=None, config=None, icon=None, timer=None, feedback=None):
    """ Score presents feedback to a participant after a Trial
    - session: a Session object
    - title: the title of the score page
    - score_message: a function which constructs feedback text based on the score
    - config: a dict with the following settings:
        - show_section: whether metadata of the previous section should be shown
        - show_total_score: whether the total score should be shown
    - icon: the name of a themify-icon shown with the view or None
    - timer: int or None. If int, wait for as many seconds until showing the next view
    - feedback: An additional feedback text
    """
    self.session = session
    self.title = title or _('Round {get_rounds_passed} / {total_rounds}').format(
        get_rounds_passed=session.get_rounds_passed(),
        total_rounds=self.session.block.rounds
    )
    self.score = score or session.last_score()
    self.score_message = score_message or self.default_score_message
    self.feedback = feedback
    self.config = {
        'show_section': False,
        'show_total_score': False
    }
    if config:
        self.config.update(config)
    self.icon = icon
    self.texts = {
        'score': _('Total Score'),
        'next': _('Next'),
        'listen_explainer': _('You listened to:')
    }
    self.timer = timer

action()

Serialize score data

Source code in experiment/actions/score.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def action(self):
    """Serialize score data"""
    # Create action
    action = {
        'view': self.ID,
        'title': self.title,
        'score': self.score,
        'score_message': self.score_message(self.score),
        'texts': self.texts,
        'feedback': self.feedback,
        'icon': self.icon,
        'timer': self.timer
    }
    if self.config['show_section']:
        action['last_song'] = self.session.last_song()
    if self.config['show_total_score']:
        action['total_score'] = self.session.total_score()
    return action

default_score_message(score)

Fallback to generate a message for the given score

Source code in experiment/actions/score.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def default_score_message(self, score):
    """Fallback to generate a message for the given score"""

    # None
    if score is None:
        score = 0
    # Zero
    if score == 0:
        # "Too bad!", "Come on!", "Try another!", "Try again!"
        return random.choice([_("No points")])
    # Negative
    if score < 0:
        return random.choice([_("Incorrect")])  # "Too bad!", "Fail!", "Nooo!"
    # Positive
    # "Well done!", "Nice job!", "Great one!", "Score!", "You're good!", "Awesome!", "Nice one!"
    return random.choice([_("Correct")])

experiment.actions.styles

experiment.actions.trial

Trial

Bases: BaseAction

A view that may include Playback and/or a Form Relates to client component: Trial.js

Parameters: - playback: player(s) to be displayed in this view - feedback_form: array of form elements - title: page title - defaults to empty

Source code in experiment/actions/trial.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class Trial(BaseAction):  # pylint: disable=too-few-public-methods
    """
    A view that may include Playback and/or a Form
    Relates to client component: Trial.js

    Parameters:
    - playback: player(s) to be displayed in this view
    - feedback_form: array of form elements
    - title: page title - defaults to empty
    """

    ID = "TRIAL_VIEW"

    def __init__(
        self,
        playback: Playback = None,
        html: str = None,
        feedback_form: Form = None,
        title="",
        config: dict = None,
        style: FrontendStyle = FrontendStyle(),
    ):
        """
        - playback: Playback object (may be None)
        - html: HTML object (may be None)
        - feedback_form: Form object (may be None)
        - title: string setting title in header of experiment
        - config: dictionary with following settings
            - response_time: how long to wait until stopping the player / proceeding to the next view
            - auto_advance: proceed to next view after player has stopped
            - listen_first: whether participant can submit before end of sound
            - break_round_on: result values upon which consecutive rounds in the current next_round array will be skipped
            - continue_label: if there is no form, how to label a button to proceed to next view
        - style: style class to add to elements in form and playback
            - neutral: first element is blue, second is yellow, third is teal
            - neutral-inverted: first element is yellow, second is blue, third is teal
            - boolean: first element is green, second is red
            - boolean-negative-first: first element is red, second is green
        """
        self.playback = playback
        self.html = html
        self.feedback_form = feedback_form
        self.title = title
        self.config = {
            "response_time": 5,
            "auto_advance": False,
            "listen_first": False,
            "show_continue_button": True,
            "continue_label": _("Continue"),
        }
        if config:
            self.config.update(config)
        self.style = style

    def action(self):
        """
        Serialize data for a block action

        """
        # Create action
        action = {
            "view": Trial.ID,
            "title": self.title,
            "config": self.config,
        }
        if self.style:
            action["style"] = self.style.to_dict()
        if self.playback:
            action["playback"] = self.playback.action()
        if self.html:
            action["html"] = self.html.action()
        if self.feedback_form:
            action["feedback_form"] = self.feedback_form.action()

        return action

__init__(playback=None, html=None, feedback_form=None, title='', config=None, style=FrontendStyle())

  • playback: Playback object (may be None)
  • html: HTML object (may be None)
  • feedback_form: Form object (may be None)
  • title: string setting title in header of experiment
  • config: dictionary with following settings
    • response_time: how long to wait until stopping the player / proceeding to the next view
    • auto_advance: proceed to next view after player has stopped
    • listen_first: whether participant can submit before end of sound
    • break_round_on: result values upon which consecutive rounds in the current next_round array will be skipped
    • continue_label: if there is no form, how to label a button to proceed to next view
  • style: style class to add to elements in form and playback
    • neutral: first element is blue, second is yellow, third is teal
    • neutral-inverted: first element is yellow, second is blue, third is teal
    • boolean: first element is green, second is red
    • boolean-negative-first: first element is red, second is green
Source code in experiment/actions/trial.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(
    self,
    playback: Playback = None,
    html: str = None,
    feedback_form: Form = None,
    title="",
    config: dict = None,
    style: FrontendStyle = FrontendStyle(),
):
    """
    - playback: Playback object (may be None)
    - html: HTML object (may be None)
    - feedback_form: Form object (may be None)
    - title: string setting title in header of experiment
    - config: dictionary with following settings
        - response_time: how long to wait until stopping the player / proceeding to the next view
        - auto_advance: proceed to next view after player has stopped
        - listen_first: whether participant can submit before end of sound
        - break_round_on: result values upon which consecutive rounds in the current next_round array will be skipped
        - continue_label: if there is no form, how to label a button to proceed to next view
    - style: style class to add to elements in form and playback
        - neutral: first element is blue, second is yellow, third is teal
        - neutral-inverted: first element is yellow, second is blue, third is teal
        - boolean: first element is green, second is red
        - boolean-negative-first: first element is red, second is green
    """
    self.playback = playback
    self.html = html
    self.feedback_form = feedback_form
    self.title = title
    self.config = {
        "response_time": 5,
        "auto_advance": False,
        "listen_first": False,
        "show_continue_button": True,
        "continue_label": _("Continue"),
    }
    if config:
        self.config.update(config)
    self.style = style

action()

Serialize data for a block action

Source code in experiment/actions/trial.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def action(self):
    """
    Serialize data for a block action

    """
    # Create action
    action = {
        "view": Trial.ID,
        "title": self.title,
        "config": self.config,
    }
    if self.style:
        action["style"] = self.style.to_dict()
    if self.playback:
        action["playback"] = self.playback.action()
    if self.html:
        action["html"] = self.html.action()
    if self.feedback_form:
        action["feedback_form"] = self.feedback_form.action()

    return action

experiment.actions.utils

final_action_with_optional_button(session, final_text='', title=_('End'), button_text=_('Continue'))

given a session, a score message and an optional session dictionary from an experiment, return a Final.action, which has a button to continue to the next block if series is defined

Source code in experiment/actions/utils.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def final_action_with_optional_button(session, final_text='', title=_('End'), button_text=_('Continue')):
    """ given a session, a score message and an optional session dictionary from an experiment,
    return a Final.action, which has a button to continue to the next block if series is defined
    """
    redirect_url = get_current_experiment_url(session)

    if redirect_url:
        return Final(
            title=title,
            session=session,
            final_text=final_text,
            button={
                'text': button_text,
                'link': redirect_url
            }
        )
    else:
        return Final(
            title=title,
            session=session,
            final_text=final_text,
        )

get_average_difference(session, num_turnpoints, initial_value)

return the average difference in milliseconds participants could hear

Source code in experiment/actions/utils.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def get_average_difference(session, num_turnpoints, initial_value):
    """
    return the average difference in milliseconds participants could hear
    """
    last_turnpoints = get_last_n_turnpoints(session, num_turnpoints)
    if last_turnpoints.count() == 0:
        last_result = get_fallback_result(session)
        if last_result:
            return float(last_result.section.song.name)
        else:
            # this cannot happen in DurationDiscrimination style blocks
            # for future compatibility, still catch the condition that there may be no results
            return initial_value
    return (sum([int(result.section.song.name) for result in last_turnpoints]) / last_turnpoints.count())

get_average_difference_level_based(session, num_turnpoints, initial_value)

calculate the difference based on exponential decay, starting from an initial_value

Source code in experiment/actions/utils.py
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def get_average_difference_level_based(session, num_turnpoints, initial_value):
    """ calculate the difference based on exponential decay,
    starting from an initial_value """
    last_turnpoints = get_last_n_turnpoints(session, num_turnpoints)
    if last_turnpoints.count() == 0:
        # outliers
        last_result = get_fallback_result(session)
        if last_result:
            return initial_value / (2 ** (int(last_result.section.song.name.split('_')[-1]) - 1))
        else:
            # participant didn't pay attention,
            # no results right after the practice rounds
            return initial_value
    # Difference by level starts at initial value (which is level 1, so 20/(2^0)) and then halves for every next level
    return sum([initial_value / (2 ** (int(result.section.song.name.split('_')[-1]) - 1)) for result in last_turnpoints]) / last_turnpoints.count()

get_fallback_result(session)

if there were no turnpoints (outliers): return the last result, or if there are no results, return None

Source code in experiment/actions/utils.py
91
92
93
94
95
96
97
98
def get_fallback_result(session):
    """ if there were no turnpoints (outliers):
    return the last result, or if there are no results, return None
    """
    if session.result_set.count() == 0:
        # stopping right after practice rounds
        return None
    return session.result_set.order_by('-created_at')[0]

get_last_n_turnpoints(session, num_turnpoints)

select all results associated with turnpoints in the result set return the last num_turnpoints results, or all turnpoint results if fewer than num_turnpoints

Source code in experiment/actions/utils.py
101
102
103
104
105
106
107
108
def get_last_n_turnpoints(session, num_turnpoints):
    """
    select all results associated with turnpoints in the result set
    return the last num_turnpoints results, or all turnpoint results if fewer than num_turnpoints
    """
    all_results = session.result_set.filter(comment__iendswith='turnpoint').order_by('-created_at').all()
    cutoff = min(all_results.count(), num_turnpoints)
    return all_results[:cutoff]

render_feedback_trivia(feedback, trivia)

Given two texts of feedback and trivia, render them in the final/feedback_trivia.html template.

Source code in experiment/actions/utils.py
50
51
52
53
54
55
def render_feedback_trivia(feedback, trivia):
    ''' Given two texts of feedback and trivia,
    render them in the final/feedback_trivia.html template.'''
    context = {'feedback': feedback, 'trivia': trivia}
    return render_to_string(join('final',
        'feedback_trivia.html'), context)

experiment.actions.wrappers

two_alternative_forced(session, section, choices, expected_response=None, style={}, comment='', scoring_rule=None, title='', config=None)

Provide data for a Two Alternative Forced view that (auto)plays a section, shows a question and has two customizable buttons

Source code in experiment/actions/wrappers.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def two_alternative_forced(session, section, choices, expected_response=None, style={}, comment='', scoring_rule=None, title='', config=None):
    """
    Provide data for a Two Alternative Forced view that (auto)plays a section,
    shows a question and has two customizable buttons
    """
    playback = PlayButton(
        [section]
    )
    key = 'choice'
    button_style = {'invisible-text': True,
                    'buttons-large-gap': True, 'buttons-large-text': True}
    button_style.update(style)
    question = ChoiceQuestion(
        key=key,
        result_id=prepare_result(
            key,
            session=session,
            section=section,
            expected_response=expected_response,
            scoring_rule=scoring_rule,
            comment=comment
        ),
        choices=choices,
        view='BUTTON_ARRAY',
        submits=True,
        style=button_style
    )
    feedback_form = Form([question])
    trial = Trial(playback=playback, feedback_form=feedback_form,
                  title=title, config=config)
    return trial