diff --git a/completion_aggregator/xapi.py b/completion_aggregator/xapi.py index 99992514..b4abac97 100644 --- a/completion_aggregator/xapi.py +++ b/completion_aggregator/xapi.py @@ -1,12 +1,104 @@ """ Transformers for completion aggregation. """ - from event_routing_backends.processors.openedx_filters.decorators import openedx_filter 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 -from tincan import Activity, ActivityDefinition, Extensions, LanguageMap, Result, Verb +from tincan import Activity, ActivityDefinition, LanguageMap, Result, Verb + +from django.utils.functional import cached_property + +XAPI_ACTIVITY_LESSON = "http://adlnet.gov/expapi/activities/lesson" + + +class BaseCompletionTransformer(XApiTransformer): + """ + Base transformer for completion events. + """ + + _verb = Verb( + id=constants.XAPI_VERB_COMPLETED, + display=LanguageMap({constants.EN: constants.COMPLETED}), + ) + object_type = None + object_id = None + + @openedx_filter( + filter_type="completion_aggregator.xapi.completion.get_object", + ) + def get_object(self): + """ + Get object for xAPI transformed event. + + Returns + ------- + `Activity` + + """ + if not self.object_type or not self.object_id: + raise NotImplementedError() + + return Activity( + id=self.object_id, + definition=ActivityDefinition( + type=self.object_type, + ), + ) + + +@XApiTransformersRegistry.register("openedx.completion_aggregator.completion.chapter") +@XApiTransformersRegistry.register("openedx.completion_aggregator.completion.sequential") +class ModuleCompletionTransformer(BaseCompletionTransformer): + """ + Transformer for events generated when a user completes a section or subsection. + """ + + object_type = constants.XAPI_ACTIVITY_MODULE + + @cached_property + def object_id(self): + """Returns the object identifier for the module completion transformer.""" + return super().get_object_iri("xblock", self.get_data("data.block_id", required=True)) + + +@XApiTransformersRegistry.register("openedx.completion_aggregator.completion.vertical") +class LessonCompletionTransformer(ModuleCompletionTransformer): + """ + Transformer for events generated when a user completes an unit. + """ + + object_type = getattr(constants, "XAPI_ACTIVITY_LESSON", XAPI_ACTIVITY_LESSON) + + +@XApiTransformersRegistry.register("openedx.completion_aggregator.completion.course") +class CourseCompletionTransformer(BaseCompletionTransformer): + """ + Transformer for event generated when a user completes a course. + """ + + object_type = constants.XAPI_ACTIVITY_COURSE + + @cached_property + def object_id(self): + """Returns the object identifier for the course completion transformer.""" + return super().get_object_iri("courses", self.get_data("data.course_id", required=True)) + + def get_context_activities(self): + """ + Retunrs context activities property. + + The XApiTransformer class implements this method and returns in the parent key + an activity that contains the course metadata however this is not necessary in + cases where a transformer uses the course metadata as object since the data is + redundant and a course cannot be its own parent, therefore this must return None. + + Returns + ------- + None + + """ + return None class BaseProgressTransformer(XApiTransformer): @@ -32,7 +124,7 @@ def get_object(self) -> Activity: raise NotImplementedError() # pragma: no cover return Activity( - id=self.get_object_iri("xblock", self.get_data("data.block_id")), + id=self.object_id, definition=ActivityDefinition( type=self.object_type, ), @@ -45,22 +137,35 @@ def get_result(self) -> Result: progress = self.get_data("data.percent") or 0 return Result( completion=progress == 1.0, - extensions=Extensions({ - constants.XAPI_ACTIVITY_PROGRESS: (progress * 100), - }), + score={ + "scaled": self.get_data("data.percent") or 0 + } ) @XApiTransformersRegistry.register("openedx.completion_aggregator.progress.chapter") @XApiTransformersRegistry.register("openedx.completion_aggregator.progress.sequential") -@XApiTransformersRegistry.register("openedx.completion_aggregator.progress.vertical") class ModuleProgressTransformer(BaseProgressTransformer): """ - Transformer for event generated when a user makes progress in a section, subsection or unit. + Transformer for event generated when a user makes progress in a section or subsection. """ object_type = constants.XAPI_ACTIVITY_MODULE + @cached_property + def object_id(self): + """Returns the object identifier for the module progress transformer.""" + return super().get_object_iri("xblock", self.get_data("data.block_id")) + + +@XApiTransformersRegistry.register("openedx.completion_aggregator.progress.vertical") +class LessonProgressTransformer(ModuleProgressTransformer): + """ + Transformer for event generated when a user makes progress in an unit. + """ + + object_type = getattr(constants, "XAPI_ACTIVITY_LESSON", XAPI_ACTIVITY_LESSON) + @XApiTransformersRegistry.register("openedx.completion_aggregator.progress.course") class CourseProgressTransformer(BaseProgressTransformer): @@ -69,3 +174,24 @@ class CourseProgressTransformer(BaseProgressTransformer): """ object_type = constants.XAPI_ACTIVITY_COURSE + + @cached_property + def object_id(self): + """Returns the object identifier for the course progress transformer.""" + return super().get_object_iri("courses", self.get_data("data.course_id")) + + def get_context_activities(self): + """ + Retunrs context activities property. + + The XApiTransformer class implements this method and returns in the parent key + an activity that contains the course metadata however this is not necessary in + cases where a transformer uses the course metadata as object since the data is + redundant and a course cannot be its own parent, therefore this must return None. + + Returns + ------- + None + + """ + return None diff --git a/tests/fixtures/expected/openedx.completion_aggregator.progress.chapter.json b/tests/fixtures/expected/openedx.completion_aggregator.progress.chapter.json index f8a8cb8f..f0c2eca6 100644 --- a/tests/fixtures/expected/openedx.completion_aggregator.progress.chapter.json +++ b/tests/fixtures/expected/openedx.completion_aggregator.progress.chapter.json @@ -43,8 +43,8 @@ }, "result":{ "completion":false, - "extensions": { - "https://w3id.org/xapi/cmi5/result/extensions/progress":50 + "score": { + "scaled": 0.5 } }, "timestamp":"2023-12-05T21:34:52.909063+00:00" diff --git a/tests/fixtures/expected/openedx.completion_aggregator.progress.course.json b/tests/fixtures/expected/openedx.completion_aggregator.progress.course.json index 2b089dc5..3a55ed26 100644 --- a/tests/fixtures/expected/openedx.completion_aggregator.progress.course.json +++ b/tests/fixtures/expected/openedx.completion_aggregator.progress.course.json @@ -22,20 +22,6 @@ }, "version":"1.0.3", "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":"056aca2a1c6b76742b283e73d3424453" @@ -43,8 +29,8 @@ }, "result":{ "completion":false, - "extensions": { - "https://w3id.org/xapi/cmi5/result/extensions/progress":80 + "score": { + "sclaed": 0.8 } }, "timestamp":"2023-12-05T21:34:52.909063+00:00" diff --git a/tests/fixtures/expected/openedx.completion_aggregator.progress.sequential.json b/tests/fixtures/expected/openedx.completion_aggregator.progress.sequential.json index 6a6aa381..717aab8f 100644 --- a/tests/fixtures/expected/openedx.completion_aggregator.progress.sequential.json +++ b/tests/fixtures/expected/openedx.completion_aggregator.progress.sequential.json @@ -43,8 +43,8 @@ }, "result":{ "completion":false, - "extensions": { - "https://w3id.org/xapi/cmi5/result/extensions/progress":60 + "score": { + "scaled": 0.6 } }, "timestamp":"2023-12-05T21:34:52.909063+00:00"