diff --git a/completion_aggregator/apps.py b/completion_aggregator/apps.py index 07595a66..53234c4b 100644 --- a/completion_aggregator/apps.py +++ b/completion_aggregator/apps.py @@ -38,6 +38,10 @@ def ready(self): """ Load signal handlers when the app is ready. """ + # pylint: disable=import-outside-toplevel from . import signals signals.register() - from .tasks import aggregation_tasks, handler_tasks # pylint: disable=unused-import + + # pylint: disable=unused-import + from . import transformers + from .tasks import aggregation_tasks, handler_tasks diff --git a/completion_aggregator/settings/aws.py b/completion_aggregator/settings/aws.py index 84852801..6a0ea5dd 100644 --- a/completion_aggregator/settings/aws.py +++ b/completion_aggregator/settings/aws.py @@ -4,11 +4,16 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from event_routing_backends.settings import production as erb_settings + def plugin_settings(settings): """ Modify the provided settings object with settings specific to this plugin. """ + # Load Event Routing Backend production settings first. + erb_settings.plugin_settings(settings) + settings.COMPLETION_AGGREGATOR_BLOCK_TYPES = set(settings.ENV_TOKENS.get( 'COMPLETION_AGGREGATOR_BLOCK_TYPES', settings.COMPLETION_AGGREGATOR_BLOCK_TYPES, diff --git a/completion_aggregator/settings/common.py b/completion_aggregator/settings/common.py index 75466014..8b015cfa 100644 --- a/completion_aggregator/settings/common.py +++ b/completion_aggregator/settings/common.py @@ -4,6 +4,8 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from event_routing_backends.utils.settings import event_tracking_backends_config + def plugin_settings(settings): """ @@ -32,6 +34,7 @@ def plugin_settings(settings): 'sequential', 'vertical', } + settings.COMPLETION_AGGREGATOR_ASYNC_AGGREGATION = False # Names of the batch operations locks @@ -52,3 +55,16 @@ def plugin_settings(settings): # 1. All courses should be reaggregated for the changes to take effect. # 2. It's not possible to revert this change by reaggregation without manually removing existing Aggregators. settings.COMPLETION_AGGREGATOR_AGGREGATE_UNRELEASED_BLOCKS = False + + # Whitelist the aggregator events for use with event routing backends xAPI backend. + enabled_aggregator_events = [ + f'openedx.completion_aggregator.{event_type}.{block_type}' + + for event_type in settings.ALLOWED_COMPLETION_AGGREGATOR_EVENT_TYPES + for block_type in settings.ALLOWED_COMPLETION_AGGREGATOR_EVENT_TYPES[event_type] + ] + settings.EVENT_TRACKING_BACKENDS_ALLOWED_XAPI_EVENTS += enabled_aggregator_events + settings.EVENT_TRACKING_BACKENDS.update(event_tracking_backends_config( + settings.EVENT_TRACKING_BACKENDS_ALLOWED_XAPI_EVENTS, + settings.EVENT_TRACKING_BACKENDS_ALLOWED_CALIPER_EVENTS, + )) diff --git a/completion_aggregator/transformers.py b/completion_aggregator/transformers.py index 0ba63667..3dcadc52 100644 --- a/completion_aggregator/transformers.py +++ b/completion_aggregator/transformers.py @@ -7,6 +7,11 @@ except ImportError: BlockStructureTransformer = object +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, LanguageMap, Result, Verb from xblock.completable import XBlockCompletionMode @@ -79,3 +84,119 @@ def transform(self, usage_info, block_structure): # pylint: disable=unused-argu if completion_mode != XBlockCompletionMode.EXCLUDED: aggregators = self.calculate_aggregators(block_structure, block_key) block_structure.set_transformer_block_field(block_key, self, self.AGGREGATORS, aggregators) + + +class BaseAggregatorXApiTransformer(XApiTransformer): + """ + Base transformer for all completion aggregator events. + """ + + object_type = None + + def get_object(self) -> Activity: + """ + Get object for xAPI transformed event. + """ + if not self.object_type: + raise NotImplementedError() + + return Activity( + id=self.get_object_iri("xblock", self.get_data("data.block_id")), + definition=ActivityDefinition( + type=self.object_type, + ), + ) + + +class BaseProgressTransformer(BaseAggregatorXApiTransformer): + """ + Base transformer for completion aggregator progress events. + """ + + _verb = Verb( + id=constants.XAPI_VERB_PROGRESSED, + display=LanguageMap({constants.EN: constants.PROGRESSED}), + ) + object_type = None + additional_fields = ('result', ) + + @openedx_filter( + filter_type="completion_aggregator.xapi.base_progress.get_object", + ) + def get_object(self) -> Activity: + """ + Get object for xAPI transformed event. + """ + return super().get_object() + + def get_result(self) -> Result: + """ + Get result for xAPI transformed event. + """ + return Result( + completion=self.get_data("data.percent") == 1.0, + 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. + """ + + object_type = constants.XAPI_ACTIVITY_MODULE + + +@XApiTransformersRegistry.register("openedx.completion_aggregator.progress.course") +class CourseProgressTransformer(BaseProgressTransformer): + """ + Transformer for event generated when a user makes progress in a course. + """ + + object_type = constants.XAPI_ACTIVITY_COURSE + + +class BaseCompletionTransformer(BaseAggregatorXApiTransformer): + """ + Base transformer for aggregator completion events. + """ + + _verb = Verb( + id=constants.XAPI_VERB_COMPLETED, + display=LanguageMap({constants.EN: constants.COMPLETED}), + ) + object_type = None + + @openedx_filter( + filter_type="completion_aggregator.xapi.base_completion.get_object", + ) + def get_object(self) -> Activity: + """ + Get object for xAPI transformed event. + """ + return super().get_object() + + +@XApiTransformersRegistry.register("openedx.completion_aggregator.completion.chapter") +@XApiTransformersRegistry.register("openedx.completion_aggregator.completion.sequential") +@XApiTransformersRegistry.register("openedx.completion_aggregator.completion.vertical") +class ModuleCompletionTransformer(BaseCompletionTransformer): + """ + Transformer for events generated when a user completes a section, subsection or unit. + """ + + object_type = constants.XAPI_ACTIVITY_MODULE + + +@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 diff --git a/tests/test_plugin_settings.py b/tests/test_plugin_settings.py new file mode 100644 index 00000000..d3c0bb9a --- /dev/null +++ b/tests/test_plugin_settings.py @@ -0,0 +1,39 @@ +""" +Test the aggregator plugin settings. +""" +from event_routing_backends.settings import common as erb_settings + +from django.conf import settings + +from completion_aggregator.settings import common as common_settings + + +def test_event_tracking_backends(): + """ + Test that the completion aggregator events are whitelisted on the ERB backends. + """ + # Event Routing Backend settings must be loaded first. + erb_settings.plugin_settings(settings) + common_settings.plugin_settings(settings) + + transformer_options = settings.EVENT_TRACKING_BACKENDS['event_transformer']['OPTIONS'] + toplevel_whitelist = set(transformer_options['processors'][0]['OPTIONS']['whitelist']) + xapi_whitelist = set(transformer_options['backends']['xapi']['OPTIONS']['processors'][0]['OPTIONS']['whitelist']) + + assert toplevel_whitelist, "No whitelist found in event_transformer processors?" + assert xapi_whitelist, "No whitelist found in event_transformer processors?" + + expected_events = { + 'openedx.completion_aggregator.progress.course', + 'openedx.completion_aggregator.progress.chapter', + 'openedx.completion_aggregator.progress.sequential', + 'openedx.completion_aggregator.progress.vertical', + 'openedx.completion_aggregator.completion.course', + 'openedx.completion_aggregator.completion.chapter', + 'openedx.completion_aggregator.completion.sequential', + 'openedx.completion_aggregator.completion.vertical', + } + + # Ensure expected_events is a subset of these whitelists + assert expected_events < toplevel_whitelist, "Aggregator events not found in event_transformer whitelist" + assert expected_events < xapi_whitelist, "Aggregator events not found in xapi whitelist"