From 675f77d8bcecb573ef34aec39eb49c1844f597b9 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Mon, 7 Aug 2023 18:43:00 +0930 Subject: [PATCH] feat: multi-question problem_check events produce multiple xAPI events Single-question problem_check events still only produce one xAPI event. Changes to the top-level multi-question problem_check event data: * object.type changed from Activity to GroupActivity * object.id shows the base problem usage_key * object.definition.interaction_type is "other" New events emitted for each child problem are identical the top-level event, except for: * object.type is Activity * object.id shows the base problem usage_key including the child usage string * object.definition.interaction_type is determined by the child problem response_type * result.score is omitted -- only relevant to the parent problem * result.response is provided, pulled from the child question submission * result.success is provided, pulled from the child question submission Related fixes to all problem_check events: * object.definition.name now shows the problem display_name * object.id now uses shows the problem usage_key * result.score max and raw are now always provided if present in the source event (bug fix) --- .coveragerc | 4 + event_routing_backends/__init__.py | 2 +- .../problem_interaction_events.py | 203 +++++++++++++++-- .../processors/xapi/statements.py | 15 ++ .../expected/problem_check(browser).json | 1 + .../problem_check(browser)legacy.json | 1 + ...roblem_check(server)_no_response_type.json | 1 + ...ck(server,multiple_questions,correct).json | 204 ++++++++++++++++- ...(server,multiple_questions,incorrect).json | 206 +++++++++++++++++- ...r,multiple_questions,partial_correct).json | 204 ++++++++++++++++- .../xapi/tests/test_transformers.py | 22 ++ .../processors/xapi/transformer.py | 117 ++++++++++ 12 files changed, 958 insertions(+), 22 deletions(-) create mode 100644 event_routing_backends/processors/xapi/statements.py diff --git a/.coveragerc b/.coveragerc index c890c53f..adb924a7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,3 +8,7 @@ omit = *admin.py *static* *templates* +[report] +exclude_lines = + pragma: no cover + raise NotImplementedError diff --git a/event_routing_backends/__init__.py b/event_routing_backends/__init__.py index 79fb3904..2fbe0f36 100644 --- a/event_routing_backends/__init__.py +++ b/event_routing_backends/__init__.py @@ -2,4 +2,4 @@ Various backends for receiving edX LMS events.. """ -__version__ = '5.5.5' +__version__ = '5.6.0' diff --git a/event_routing_backends/processors/xapi/event_transformers/problem_interaction_events.py b/event_routing_backends/processors/xapi/event_transformers/problem_interaction_events.py index 6536b70f..3f9d2806 100644 --- a/event_routing_backends/processors/xapi/event_transformers/problem_interaction_events.py +++ b/event_routing_backends/processors/xapi/event_transformers/problem_interaction_events.py @@ -6,7 +6,13 @@ from event_routing_backends.helpers import get_problem_block_id from event_routing_backends.processors.xapi import constants from event_routing_backends.processors.xapi.registry import XApiTransformersRegistry -from event_routing_backends.processors.xapi.transformer import XApiTransformer, XApiVerbTransformerMixin +from event_routing_backends.processors.xapi.statements import GroupActivity +from event_routing_backends.processors.xapi.transformer import ( + OneToManyChildXApiTransformerMixin, + OneToManyXApiTransformerMixin, + XApiTransformer, + XApiVerbTransformerMixin, +) # map open edx problems interation types to xAPI valid interaction types INTERACTION_TYPES_MAP = { @@ -76,19 +82,41 @@ def get_object(self): Returns: `Activity` """ + object_id = self.get_object_id() + definition = self.get_object_definition() + return Activity( + id=object_id, + definition=definition, + ) + + def get_object_id(self): + """ + Returns the object.id + + Returns: + str + """ object_id = None data = self.get_data('data') if data and isinstance(data, dict): object_id = self.get_data('data.problem_id') or self.get_data('data.module_id', True) + else: + object_id = self.get_data('usage_key') + + return object_id + def get_object_definition(self): + """ + Returns the definition portion of the object stanza. + + Returns: + ActivityDefinition + """ event_name = self.get_data('name', True) - return Activity( - id=object_id, - definition=ActivityDefinition( - type=EVENT_OBJECT_DEFINITION_TYPE[event_name] if event_name in EVENT_OBJECT_DEFINITION_TYPE else - constants.XAPI_ACTIVITY_INTERACTION, - ), + return ActivityDefinition( + type=EVENT_OBJECT_DEFINITION_TYPE[event_name] if event_name in EVENT_OBJECT_DEFINITION_TYPE else + constants.XAPI_ACTIVITY_INTERACTION, ) @@ -169,10 +197,16 @@ def get_result(self): ) -@XApiTransformersRegistry.register('problem_check') -class ProblemCheckTransformer(BaseProblemsTransformer): +class BaseProblemCheckTransformer(BaseProblemsTransformer): """ - Transform problem interaction related events into xAPI format. + Transform problem check events into one or more xAPI statements. + + If there is only one question in the source event problem, then transform() returns a single Activity. + + But if there are multiple questions in the source event problem, transform() will return: + + * 1 parent GroupActivity + * N "child" Activity which reference the parent, where N>=0 """ additional_fields = ('result', ) @@ -201,19 +235,33 @@ def get_object(self): if xapi_object.id: xapi_object.id = self.get_object_iri('xblock', xapi_object.id) + return xapi_object + + def get_object_definition(self): + """ + Returns the definition portion of the object stanza. + + Returns: + ActivityDefinition + """ + definition = super().get_object_definition() + if self.get_data('data.attempts'): - xapi_object.definition.extensions = Extensions({ + definition.extensions = Extensions({ constants.XAPI_ACTIVITY_ATTEMPT: self.get_data('data.attempts') }) interaction_type = self._get_interaction_type() + display_name = self.get_data('display_name') submission = self._get_submission() if submission: - interaction_type = INTERACTION_TYPES_MAP[submission['response_type']] - xapi_object.definition.description = LanguageMap({constants.EN_US: submission['question']}) + interaction_type = INTERACTION_TYPES_MAP.get(submission.get('response_type'), DEFAULT_INTERACTION_TYPE) + definition.description = LanguageMap({constants.EN_US: submission['question']}) + elif display_name: + definition.name = LanguageMap({constants.EN_US: display_name}) - xapi_object.definition.interaction_type = interaction_type + definition.interaction_type = interaction_type - return xapi_object + return definition def _get_submission(self): """ @@ -265,11 +313,13 @@ def get_result(self): submission = self._get_submission() if submission: response = submission["answer"] + correct = submission.get("correct") else: response = event_data.get('answers', None) + correct = self.get_data('success') == 'correct' - max_grade = event_data.get('max_grade', None) - grade = event_data.get('grade', None) + max_grade = self.get_data('max_grade') + grade = self.get_data('grade') scaled = None if max_grade is not None and grade is not None: @@ -279,7 +329,7 @@ def get_result(self): scaled = 0 return Result( - success=event_data.get('success', None) == 'correct', + success=correct, score={ 'min': 0, 'max': max_grade, @@ -288,3 +338,120 @@ def get_result(self): }, response=response ) + + +@XApiTransformersRegistry.register('problem_check') +class ProblemCheckTransformer(OneToManyXApiTransformerMixin, BaseProblemCheckTransformer): + """ + Transform problem check events into one or more xAPI statements. + + If there is only one question in the source event problem, then transform() returns a single Activity. + + But if there are multiple questions in the source event problem, transform() will return: + + * 1 parent GroupActivity + * N "child" Activity which reference the parent, where N>=0 + """ + @property + def child_transformer_class(self): + """ + Returns the ProblemCheckChildTransformer class. + + Returns: + Type + """ + return ProblemCheckChildTransformer + + def get_child_ids(self): + """ + Returns the list of "child" event IDs. + + In this context, "child" events relate to multiple submissions to sub-questions in the problem. + + If <1 children are found on this event, then <1 child events are returned in the list. + Otherwise, we say that "this event has no children", and so this method returns an empty list. + + Returns: + list of strings + """ + submissions = self.get_data('submission') or {} + child_ids = submissions.keys() + if len(child_ids) > 1: + return child_ids + return [] + + def get_object(self): + """ + Get object for xAPI transformed event or group of events. + + Returns: + `Activity` or `GroupActivity` + """ + activity = super().get_object() + definition = self.get_object_definition() + + if self.get_child_ids(): + activity = GroupActivity( + id=activity.id, + definition=definition, + ) + + return activity + + def get_object_definition(self): + """ + Returns the definition portion of the object stanza. + + Returns: + ActivityDefinition + """ + definition = super().get_object_definition() + + if self.get_child_ids(): + definition.interaction_type = DEFAULT_INTERACTION_TYPE + + return definition + + +class ProblemCheckChildTransformer(OneToManyChildXApiTransformerMixin, BaseProblemCheckTransformer): + """ + Transformer for subproblems of a multi-question problem_check event. + """ + def _get_submission(self): + """ + Return this child's submission data from the event data, if valid. + + Returns: + dict + """ + submissions = self.get_data('submission') or {} + return submissions.get(self.child_id) + + def get_object_id(self): + """ + Returns the child object.id, which it creates from the parent object.id + and the child_id. + + Returns: + str + """ + object_id = super().get_object_id() or "" + object_id = '@'.join([ + *object_id.split('@')[:-1], + self.child_id, + ]) + return object_id + + def get_result(self): + """ + Get result for the xAPI transformed child event. + + Returns: + `Result` + """ + result = super().get_result() + # Don't report the score on child events; only the parent knows the score. + result.score = None + submission = self._get_submission() or {} + result.response = submission.get('answer') + return result diff --git a/event_routing_backends/processors/xapi/statements.py b/event_routing_backends/processors/xapi/statements.py new file mode 100644 index 00000000..11951308 --- /dev/null +++ b/event_routing_backends/processors/xapi/statements.py @@ -0,0 +1,15 @@ +""" +xAPI statement classes +""" +from tincan import Activity + + +class GroupActivity(Activity): + """ + Subclass of tincan.Activity which reports object_type="GroupActivity" + + For use with Activites that contain one or more child Activities, like Problems that contain multiple Questions. + """ + @Activity.object_type.setter + def object_type(self, _): + self._object_type = 'GroupActivity' diff --git a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(browser).json b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(browser).json index 2a7eef9a..54831e74 100644 --- a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(browser).json +++ b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(browser).json @@ -26,6 +26,7 @@ }, "object": { "definition": { + "interactionType": "other", "type": "http://adlnet.gov/expapi/activities/cmi.interaction" }, "id": "http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@3fc5461f86764ad7bdbdf6cbdde61e66", diff --git a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(browser)legacy.json b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(browser)legacy.json index 2d04afa4..98962f50 100644 --- a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(browser)legacy.json +++ b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(browser)legacy.json @@ -26,6 +26,7 @@ }, "object": { "definition": { + "interactionType": "other", "type": "http://adlnet.gov/expapi/activities/cmi.interaction" }, "id": "http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@ef37eb3cf1724e38b7f88a9ce85a4842", diff --git a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server)_no_response_type.json b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server)_no_response_type.json index 81c84bc8..2fe58cab 100644 --- a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server)_no_response_type.json +++ b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server)_no_response_type.json @@ -29,6 +29,7 @@ "extensions":{ "http://id.tincanapi.com/extension/attempt-id": 10 }, + "name": {"en-US": "Checkboxes"}, "interactionType": "other", "type": "http://adlnet.gov/expapi/activities/cmi.interaction" }, diff --git a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,correct).json b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,correct).json index f3fe2a7b..f47b6ae9 100644 --- a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,correct).json +++ b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,correct).json @@ -1,6 +1,186 @@ +[ { "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", - "result": {"score": {"min": 0.0}, "success": false}, + "result": {"score": {"min": 0, "max": 3, "raw": 3, "scaled": 1}, "success": true}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": {"en-US": "Multiple Choice Questions"}, + "interactionType": "other" + } + }, + "context": { + "contextActivities": { + "parent": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "97662bef7c463c187b8fd91e0f580468" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "blue", "success": true}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_2_1", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "What color is the open ocean on a sunny day?" + }, + "interactionType": "choice" + } + }, + "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, + "contextActivities": { + "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "97662bef7c463c187b8fd91e0f580468" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "['a piano', 'a guitar']", "success": true}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_4_1", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "Which of the following are musical instruments?" + }, + "interactionType": "choice" + } + }, + "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, + "contextActivities": { + "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "97662bef7c463c187b8fd91e0f580468" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "a chair", "success": true}, "version": "1.0.3", "actor": { "objectType": "Agent", @@ -17,14 +197,35 @@ }, "object": { "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_3_1", "definition": { "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "Which piece of furniture is built for sitting?" + }, "interactionType": "choice" } }, "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, "contextActivities": { "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ { "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", "objectType": "Activity", @@ -43,3 +244,4 @@ } } } +] diff --git a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,incorrect).json b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,incorrect).json index f3fe2a7b..41541ab7 100644 --- a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,incorrect).json +++ b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,incorrect).json @@ -1,6 +1,188 @@ +[ { "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", - "result": {"score": {"min": 0.0}, "success": false}, + "result": {"score": {"min": 0, "max": 3, "raw": 0, "scaled": 0}, "success": false}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + }, + "context": { + "contextActivities": { + "parent": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "97662bef7c463c187b8fd91e0f580468" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "yellow", "success": false}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_2_1", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "What color is the open ocean on a sunny day?" + }, + "interactionType": "choice" + } + }, + "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, + "contextActivities": { + "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "97662bef7c463c187b8fd91e0f580468" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "['a window']", "success": false}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_4_1", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "Which of the following are musical instruments?" + }, + "interactionType": "choice" + } + }, + "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, + "contextActivities": { + "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "97662bef7c463c187b8fd91e0f580468" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "a bookshelf", "success": false}, "version": "1.0.3", "actor": { "objectType": "Agent", @@ -17,14 +199,35 @@ }, "object": { "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_3_1", "definition": { "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "Which piece of furniture is built for sitting?" + }, "interactionType": "choice" } }, "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, "contextActivities": { "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ { "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", "objectType": "Activity", @@ -43,3 +246,4 @@ } } } +] diff --git a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,partial_correct).json b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,partial_correct).json index b6c3ad1c..d8014195 100644 --- a/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,partial_correct).json +++ b/event_routing_backends/processors/xapi/tests/fixtures/expected/problem_check(server,multiple_questions,partial_correct).json @@ -1,6 +1,186 @@ +[ { "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", - "result": {"score": {"min": 0.0}, "success": false}, + "result": {"score": {"min": 0, "max": 3, "raw": 2, "scaled": 0.6666666666666666}, "success": false}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": {"en-US": "Multiple Choice Questions"}, + "interactionType": "other" + } + }, + "context": { + "contextActivities": { + "parent": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "2c65c4e9e6a637feb42426fd35b5dac3" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "blue", "success": true}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_2_1", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "What color is the open ocean on a sunny day?" + }, + "interactionType": "choice" + } + }, + "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, + "contextActivities": { + "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "2c65c4e9e6a637feb42426fd35b5dac3" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "['a piano', 'a guitar']", "success": true}, + "version": "1.0.3", + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "verb": { + "id": "https://w3id.org/xapi/acrossx/verbs/evaluated", + "display": { + "en": "evaluated" + } + }, + "object": { + "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_4_1", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "Which of the following are musical instruments?" + }, + "interactionType": "choice" + } + }, + "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, + "contextActivities": { + "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ + { + "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", + "objectType": "Activity", + "definition": { + "name": { + "en-US": "Demonstration Course" + }, + "type": "http://adlnet.gov/expapi/activities/course" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/openedx/extension/transformer-version": "event-routing-backends@1.1.1", + "https://w3id.org/xapi/openedx/extensions/session-id": "2c65c4e9e6a637feb42426fd35b5dac3" + } + } +}, +{ + "id": "6d1f033b-3f70-458c-b53a-e6bb63cbaef9", + "result": {"response": "a bookshelf", "success": false}, "version": "1.0.3", "actor": { "objectType": "Agent", @@ -17,14 +197,35 @@ }, "object": { "objectType": "Activity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4_3_1", "definition": { "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "description": { + "en-US": "Which piece of furniture is built for sitting?" + }, "interactionType": "choice" } }, "context": { + "statement": { + "id": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "objectType": "StatementRef" + }, "contextActivities": { "parent": [ + { + "objectType": "GroupActivity", + "id" :"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4", + "definition": { + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "name": { + "en-US": "Multiple Choice Questions" + }, + "interactionType": "other" + } + } + ], + "grouping": [ { "id": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course", "objectType": "Activity", @@ -43,3 +244,4 @@ } } } +] diff --git a/event_routing_backends/processors/xapi/tests/test_transformers.py b/event_routing_backends/processors/xapi/tests/test_transformers.py index d40844a6..a3cc28ce 100644 --- a/event_routing_backends/processors/xapi/tests/test_transformers.py +++ b/event_routing_backends/processors/xapi/tests/test_transformers.py @@ -31,6 +31,28 @@ def compare_events(self, transformed_event, expected_event): """ Test that transformed_event and expected_event are identical. + Arguments: + transformed_event (dict or list) + expected_event (dict or list) + + Raises: + AssertionError: Raised if the two events are not same. + """ + # Compare lists of events + if isinstance(expected_event, list): + assert isinstance(transformed_event, list) + assert len(transformed_event) == len(expected_event) + for idx, e_event in enumerate(expected_event): + self._compare_events(transformed_event[idx], e_event) + + # Compare single events + else: + self._compare_events(transformed_event, expected_event) + + def _compare_events(self, transformed_event, expected_event): + """ + Test that transformed_event and expected_event are identical. + Arguments: transformed_event (dict) expected_event (dict) diff --git a/event_routing_backends/processors/xapi/transformer.py b/event_routing_backends/processors/xapi/transformer.py index ee2860ad..3be1843c 100644 --- a/event_routing_backends/processors/xapi/transformer.py +++ b/event_routing_backends/processors/xapi/transformer.py @@ -14,6 +14,7 @@ Extensions, LanguageMap, Statement, + StatementRef, Verb, ) @@ -178,3 +179,119 @@ def verb(self): id=verb['id'], display=LanguageMap({constants.EN: verb['display']}) ) + + +class OneToManyXApiTransformerMixin: + """ + Abstract mixin that helps transform a single input event into: + + * 1 parent xAPI event, plus + * N "child" xAPI events, where N>=0 + """ + @property + def child_transformer_class(self): + """ + Abstract property which returns the transformer class to use when transforming the child events. + + Should inherit from OneToManyChildXApiTransformerMixin. + + Returns: + Type + """ + raise NotImplementedError + + def get_child_ids(self): + """ + Abstract method which returns the list of "child" event IDs from the parent event data. + + Returns: + list of strings + """ + raise NotImplementedError + + def transform(self): + """ + Transform the edX event into a list of events, if there is child data. + + If transform_child_events() is Falsey, then only the parent event is returned. + Otherwise, returns a list containing the parent event, followed by any child events. + + Returns: + ANY, or list of ANY + """ + parent_event = super().transform() + child_events = self.transform_children(parent_event) + if child_events: + return [parent_event, *child_events] + return parent_event + + def transform_children(self, parent): + """ + Transform the children of the parent xAPI event. + + + Returns: + list of ANY + """ + child_ids = self.get_child_ids() + ChildTransformer = self.child_transformer_class + return [ + ChildTransformer( + child_id=child_id, + parent=parent, + event=self.event, + ).transform() for child_id in child_ids + ] + + +class OneToManyChildXApiTransformerMixin: + """ + Mixin for processing child xAPI events from a parent transformer. + + This class handles initialization, and adds methods for the expected stanzas in the transformed child event. + + The parent event transformer should inherit from OneToManyXApiTransformer. + """ + def __init__(self, parent, child_id, *args, **kwargs): + """ + Stores the parent event transformer, and this child's identifier, + for use when transforming the child data. + """ + super().__init__(*args, **kwargs) + self.parent = parent + self.child_id = child_id + + def get_context(self): + """ + Returns the context for the xAPI transformed child event. + + Returns: + `Context` + """ + return Context( + extensions=self.get_context_extensions(), + contextActivities=self.get_context_activities(), + statement=self.get_context_statement(), + ) + + def get_context_activities(self): + """ + Get context activities for xAPI transformed event. + + Returns: + `ContextActivities` + """ + return ContextActivities( + parent=ActivityList([self.parent.object]), + grouping=ActivityList(self.parent.context.context_activities.parent), + ) + + def get_context_statement(self): + """ + Returns a StatementRef that refers to the parent event. + Returns: + `StatementRef` + """ + return StatementRef( + id=self.parent.id, + )