diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea2db829..bed529dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - Implement edX open response assessment events pydantic models +- Implement edx peer instruction events pydantic models ### Changed diff --git a/src/ralph/models/edx/__init__.py b/src/ralph/models/edx/__init__.py index b8011a365..c55cee81a 100644 --- a/src/ralph/models/edx/__init__.py +++ b/src/ralph/models/edx/__init__.py @@ -24,6 +24,11 @@ ORASubmitFeedbackOnAssessments, ORAUploadFile, ) +from .peer_instruction.statements import ( + PeerInstructionAccessed, + PeerInstructionOriginalSubmitted, + PeerInstructionRevisedSubmitted, +) from .problem_interaction.statements import ( EdxProblemHintDemandhintDisplayed, EdxProblemHintFeedbackDisplayed, diff --git a/src/ralph/models/edx/peer_instruction/__init__.py b/src/ralph/models/edx/peer_instruction/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/src/ralph/models/edx/peer_instruction/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/src/ralph/models/edx/peer_instruction/fields/__init__.py b/src/ralph/models/edx/peer_instruction/fields/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/src/ralph/models/edx/peer_instruction/fields/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/src/ralph/models/edx/peer_instruction/fields/events.py b/src/ralph/models/edx/peer_instruction/fields/events.py new file mode 100644 index 000000000..83b8af10e --- /dev/null +++ b/src/ralph/models/edx/peer_instruction/fields/events.py @@ -0,0 +1,22 @@ +"""Peer instruction event field definition.""" + +from pydantic import constr + +from ...base import AbstractBaseEventField + + +class PeerInstructionEventField(AbstractBaseEventField): + """Pydantic model for peer instruction `event` field. + + Attributes: + answer (int): Consists of the index assigned to the answer choice selected by + the learner. + rationale (str): Consists of the text entered by the learner to explain why + they selected that answer choice. + truncated (bool): `True` only if the rationale was longer than 12,500 + characters, which is the maximum included in the event. + """ + + answer: int + rationale: constr(max_length=12500) + truncated: bool diff --git a/src/ralph/models/edx/peer_instruction/statements.py b/src/ralph/models/edx/peer_instruction/statements.py new file mode 100644 index 000000000..833fd5d06 --- /dev/null +++ b/src/ralph/models/edx/peer_instruction/statements.py @@ -0,0 +1,86 @@ +"""Peer instruction events model definitions.""" + +from typing import Union + +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + +from pydantic import Json + +from ralph.models.selector import selector + +from ..server import BaseServerModel +from .fields.events import PeerInstructionEventField + + +class PeerInstructionAccessed(BaseServerModel): + """Pydantic model for `ubc.peer_instruction.accessed` statement. + + The server emits this event when a peer instruction question and its set of answer + choices is shown to a learner. + + Attributes: + event_type (str): Consists of the value `ubc.peer_instruction.accessed`. + name (str): Consists of the value `ubc.peer_instruction.accessed`. + """ + + __selector__ = selector( + event_source="server", event_type="ubc.peer_instruction.accessed" + ) + + event_type: Literal["ubc.peer_instruction.accessed"] + name: Literal["ubc.peer_instruction.accessed"] + + +class PeerInstructionOriginalSubmitted(BaseServerModel): + """Pydantic model for `ubc.peer_instruction.original_submitted` statement. + + The server emits this event when learners submit their initial responses. These + events record the answer choice the learner selected and the explanation given + for why that selection was made. + + Attributes: + event (int): See PeerInstructionEventField. + event_type (str): Consists of the value + `ubc.peer_instruction.original_submitted`. + name (str): Consists of the value `ubc.peer_instruction.original_submitted`. + """ + + __selector__ = selector( + event_source="server", event_type="ubc.peer_instruction.original_submitted" + ) + + event: Union[ + Json[PeerInstructionEventField], # pylint: disable=unsubscriptable-object + PeerInstructionEventField, + ] + event_type: Literal["ubc.peer_instruction.original_submitted"] + name: Literal["ubc.peer_instruction.original_submitted"] + + +class PeerInstructionRevisedSubmitted(BaseServerModel): + """Pydantic model for `ubc.peer_instruction.revised_submitted` statement. + + The server emits this event when learners submit their revised responses. These + events record the answer choice selected by the learner and the explanation for + why that selection was made. + + Attributes: + event (int): See PeerInstructionEventField. + event_type (str): Consists of the value + `ubc.peer_instruction.revised_submitted`. + name (str): Consists of the value `ubc.peer_instruction.revised_submitted`. + """ + + __selector__ = selector( + event_source="server", event_type="ubc.peer_instruction.revised_submitted" + ) + + event: Union[ + Json[PeerInstructionEventField], # pylint: disable=unsubscriptable-object + PeerInstructionEventField, + ] + event_type: Literal["ubc.peer_instruction.revised_submitted"] + name: Literal["ubc.peer_instruction.revised_submitted"] diff --git a/tests/models/edx/peer_instruction/__init__.py b/tests/models/edx/peer_instruction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/models/edx/peer_instruction/test_events.py b/tests/models/edx/peer_instruction/test_events.py new file mode 100644 index 000000000..244b9c649 --- /dev/null +++ b/tests/models/edx/peer_instruction/test_events.py @@ -0,0 +1,32 @@ +"""Tests for peer instruction models event fields.""" + +import json + +import pytest +from pydantic.error_wrappers import ValidationError + +from ralph.models.edx.peer_instruction.fields.events import PeerInstructionEventField + +from tests.fixtures.hypothesis_strategies import custom_given + + +@custom_given(PeerInstructionEventField) +def test_models_edx_peer_instruction_event_field_with_valid_field(field): + """Tests that a valid `PeerInstructionEventField` does not raise a + `ValidationError`. + """ + assert len(field.rationale) <= 12500 + + +@custom_given(PeerInstructionEventField) +def test_models_edx_peer_instruction_event_field_with_invalid_rationale(field): + """Tests that a valid `PeerInstructionEventField` does not raise a + `ValidationError`. + """ + invalid_field = json.loads(field.json()) + invalid_field["rationale"] = "x" * 12501 + with pytest.raises( + ValidationError, + match="rationale\n ensure this value has at most 12500 characters", + ): + PeerInstructionEventField(**invalid_field) diff --git a/tests/models/edx/peer_instruction/test_statements.py b/tests/models/edx/peer_instruction/test_statements.py new file mode 100644 index 000000000..9b0ba79dc --- /dev/null +++ b/tests/models/edx/peer_instruction/test_statements.py @@ -0,0 +1,63 @@ +"""Tests for the peer_instruction event models.""" + +import json + +import pytest +from hypothesis import strategies as st + +from ralph.models.edx.peer_instruction.statements import ( + PeerInstructionAccessed, + PeerInstructionOriginalSubmitted, + PeerInstructionRevisedSubmitted, +) +from ralph.models.selector import ModelSelector + +from tests.fixtures.hypothesis_strategies import custom_builds, custom_given + + +@pytest.mark.parametrize( + "class_", + [ + PeerInstructionAccessed, + PeerInstructionOriginalSubmitted, + PeerInstructionRevisedSubmitted, + ], +) +@custom_given(st.data()) +def test_models_edx_peer_instruction_selectors_with_valid_statements(class_, data): + """Tests given a valid peer_instruction edX statement the `get_first_model` + selector method should return the expected model. + """ + statement = json.loads(data.draw(custom_builds(class_)).json()) + model = ModelSelector(module="ralph.models.edx").get_first_model(statement) + assert model is class_ + + +@custom_given(PeerInstructionAccessed) +def test_models_edx_peer_instruction_accessed_with_valid_statement( + statement, +): + """Tests that a `ubc.peer_instruction.accessed` statement has the expected + `event_type`.""" + assert statement.event_type == "ubc.peer_instruction.accessed" + assert statement.name == "ubc.peer_instruction.accessed" + + +@custom_given(PeerInstructionOriginalSubmitted) +def test_models_edx_peer_instruction_original_submitted_with_valid_statement( + statement, +): + """Tests that a `ubc.peer_instruction.original_submitted` statement has the + expected `event_type`.""" + assert statement.event_type == "ubc.peer_instruction.original_submitted" + assert statement.name == "ubc.peer_instruction.original_submitted" + + +@custom_given(PeerInstructionRevisedSubmitted) +def test_models_edx_peer_instruction_revised_submitted_with_valid_statement( + statement, +): + """Tests that a `ubc.peer_instruction.revised_submitted` statement has the + expected `event_type`.""" + assert statement.event_type == "ubc.peer_instruction.revised_submitted" + assert statement.name == "ubc.peer_instruction.revised_submitted"