From 79cc42eb5710886dbd5f641d34a68750ee7b6cff Mon Sep 17 00:00:00 2001 From: andrey-canon Date: Thu, 12 Dec 2024 18:59:41 -0500 Subject: [PATCH] feat: add completion transformers --- completion_aggregator/xapi.py | 142 +++++++++++++++++- ...tion_aggregator.completion.sequential.json | 45 ++++++ ...letion_aggregator.completion.vertical.json | 45 ++++++ ...ompletion_aggregator.progress.chapter.json | 4 +- ...completion_aggregator.progress.course.json | 20 +-- ...letion_aggregator.progress.sequential.json | 4 +- ...mpletion_aggregator.progress.vertical.json | 8 +- ...tion_aggregator.completion.sequential.json | 30 ++++ ...letion_aggregator.completion.vertical.json | 30 ++++ ...mpletion_aggregator.progress.vertical.json | 4 +- 10 files changed, 297 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/expected/openedx.completion_aggregator.completion.sequential.json create mode 100644 tests/fixtures/expected/openedx.completion_aggregator.completion.vertical.json create mode 100644 tests/fixtures/raw/openedx.completion_aggregator.completion.sequential.json create mode 100644 tests/fixtures/raw/openedx.completion_aggregator.completion.vertical.json 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.completion.sequential.json b/tests/fixtures/expected/openedx.completion_aggregator.completion.sequential.json new file mode 100644 index 00000000..d54194b9 --- /dev/null +++ b/tests/fixtures/expected/openedx.completion_aggregator.completion.sequential.json @@ -0,0 +1,45 @@ +{ + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "id":"484fe8d7-7a5b-52ff-a0ab-3d3d8c1a8b27", + "object":{ + "id":"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bf1eac10ebb649e3aaf9cc07325f8e04", + "definition":{ + "type":"http://adlnet.gov/expapi/activities/module" + }, + "objectType":"Activity" + }, + "verb":{ + "id":"http://adlnet.gov/expapi/verbs/completed", + "display":{ + "en":"completed" + } + }, + "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" + } + }, + "timestamp":"2023-12-05T21:34:52.909063+00:00" +} diff --git a/tests/fixtures/expected/openedx.completion_aggregator.completion.vertical.json b/tests/fixtures/expected/openedx.completion_aggregator.completion.vertical.json new file mode 100644 index 00000000..b3c72999 --- /dev/null +++ b/tests/fixtures/expected/openedx.completion_aggregator.completion.vertical.json @@ -0,0 +1,45 @@ +{ + "actor": { + "objectType": "Agent", + "account": { + "name": "32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb", + "homePage": "http://localhost:18000" + } + }, + "id":"484fe8d7-7a5b-52ff-a0ab-3d3d8c1a8b27", + "object":{ + "id":"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@vertical+block@e1fabd9fa55f441caa75580f258ffbc3", + "definition":{ + "type":"http://adlnet.gov/expapi/activities/lesson" + }, + "objectType":"Activity" + }, + "verb":{ + "id":"http://adlnet.gov/expapi/verbs/completed", + "display":{ + "en":"completed" + } + }, + "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" + } + }, + "timestamp":"2023-12-05T21:34:52.909063+00:00" +} 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..d98b3f63 100644 --- a/tests/fixtures/expected/openedx.completion_aggregator.progress.course.json +++ b/tests/fixtures/expected/openedx.completion_aggregator.progress.course.json @@ -8,7 +8,7 @@ }, "id":"146d5372-1d64-54b1-8c60-b4acaad3c976", "object":{ - "id":"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@course+block@course", + "id":"http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course", "definition":{ "type":"http://adlnet.gov/expapi/activities/course" }, @@ -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": { + "scaled": 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" diff --git a/tests/fixtures/expected/openedx.completion_aggregator.progress.vertical.json b/tests/fixtures/expected/openedx.completion_aggregator.progress.vertical.json index b2692614..e11b24e5 100644 --- a/tests/fixtures/expected/openedx.completion_aggregator.progress.vertical.json +++ b/tests/fixtures/expected/openedx.completion_aggregator.progress.vertical.json @@ -10,7 +10,7 @@ "object":{ "id":"http://localhost:18000/xblock/block-v1:edX+DemoX+Demo_Course+type@vertical+block@e1fabd9fa55f441caa75580f258ffbc3", "definition":{ - "type":"http://adlnet.gov/expapi/activities/module" + "type":"http://adlnet.gov/expapi/activities/lesson" }, "objectType":"Activity" }, @@ -42,9 +42,9 @@ } }, "result":{ - "completion":true, - "extensions": { - "https://w3id.org/xapi/cmi5/result/extensions/progress":100 + "completion":false, + "score": { + "scaled": 0.9 } }, "timestamp":"2023-12-05T21:34:52.909063+00:00" diff --git a/tests/fixtures/raw/openedx.completion_aggregator.completion.sequential.json b/tests/fixtures/raw/openedx.completion_aggregator.completion.sequential.json new file mode 100644 index 00000000..2864b0d8 --- /dev/null +++ b/tests/fixtures/raw/openedx.completion_aggregator.completion.sequential.json @@ -0,0 +1,30 @@ +{ + "name": "openedx.completion_aggregator.completion.sequential", + "timestamp": "2023-12-05T21:34:52.909063+00:00", + "data": { + "user_id": 4, + "course_id": "course-v1:edX+DemoX+Demo_Course", + "context_key": "course-v1:edX+DemoX+Demo_Course", + "block_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bf1eac10ebb649e3aaf9cc07325f8e04", + "block_type": "course", + "percent": 1, + "earned": 10, + "possible": 10 + }, + "context": { + "course_id": "course-v1:edX+DemoX+Demo_Course", + "course_user_tags": {}, + "session": "056aca2a1c6b76742b283e73d3424453", + "user_id": 3, + "username": "openedx", + "ip": "172.18.0.1", + "host": "localhost:18000", + "agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", + "path": "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@7c54b16c8ed34f9f8772015178c7a175/handler/xmodule_handler/problem_check", + "referer": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course+type@vertical+block@dd8110c941b94d929b56841195213797?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view", + "accept_language": "en-US,en;q=0.9,es;q=0.8", + "client_id": null, + "org_id": "edX", + "enterprise_uuid": "" + } +} diff --git a/tests/fixtures/raw/openedx.completion_aggregator.completion.vertical.json b/tests/fixtures/raw/openedx.completion_aggregator.completion.vertical.json new file mode 100644 index 00000000..e179db5e --- /dev/null +++ b/tests/fixtures/raw/openedx.completion_aggregator.completion.vertical.json @@ -0,0 +1,30 @@ +{ + "name": "openedx.completion_aggregator.completion.vertical", + "timestamp": "2023-12-05T21:34:52.909063+00:00", + "data": { + "user_id": 4, + "course_id": "course-v1:edX+DemoX+Demo_Course", + "context_key": "course-v1:edX+DemoX+Demo_Course", + "block_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@e1fabd9fa55f441caa75580f258ffbc3", + "block_type": "course", + "percent": 1, + "earned": 10, + "possible": 10 + }, + "context": { + "course_id": "course-v1:edX+DemoX+Demo_Course", + "course_user_tags": {}, + "session": "056aca2a1c6b76742b283e73d3424453", + "user_id": 3, + "username": "openedx", + "ip": "172.18.0.1", + "host": "localhost:18000", + "agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", + "path": "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@problem+block@7c54b16c8ed34f9f8772015178c7a175/handler/xmodule_handler/problem_check", + "referer": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course+type@vertical+block@dd8110c941b94d929b56841195213797?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view", + "accept_language": "en-US,en;q=0.9,es;q=0.8", + "client_id": null, + "org_id": "edX", + "enterprise_uuid": "" + } +} diff --git a/tests/fixtures/raw/openedx.completion_aggregator.progress.vertical.json b/tests/fixtures/raw/openedx.completion_aggregator.progress.vertical.json index 3d8d8a49..f92f3bc7 100644 --- a/tests/fixtures/raw/openedx.completion_aggregator.progress.vertical.json +++ b/tests/fixtures/raw/openedx.completion_aggregator.progress.vertical.json @@ -7,8 +7,8 @@ "context_key": "course-v1:edX+DemoX+Demo_Course", "block_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@e1fabd9fa55f441caa75580f258ffbc3", "block_type": "course", - "percent": 1, - "earned": 10, + "percent": 0.9, + "earned": 9, "possible": 10 }, "context": {