Skip to content

Commit

Permalink
feat: multi-question problem_check events produce multiple xAPI events
Browse files Browse the repository at this point in the history
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 always provided (bug fix)
* result.score.scale is only provided if max_grade is not None
  • Loading branch information
pomegranited committed Aug 7, 2023
1 parent 7f64631 commit 6ad3492
Show file tree
Hide file tree
Showing 12 changed files with 958 additions and 22 deletions.
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ omit =
*admin.py
*static*
*templates*
[report]
exclude_lines =
pragma: no cover
raise NotImplementedError
2 changes: 1 addition & 1 deletion event_routing_backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Various backends for receiving edX LMS events..
"""

__version__ = '5.5.5'
__version__ = '5.6.0'
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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', )

Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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
15 changes: 15 additions & 0 deletions event_routing_backends/processors/xapi/statements.py
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading

0 comments on commit 6ad3492

Please sign in to comment.