diff --git a/completion_aggregator/models.py b/completion_aggregator/models.py index c1fde462..17a44d9e 100644 --- a/completion_aggregator/models.py +++ b/completion_aggregator/models.py @@ -5,6 +5,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from eventtracking import tracker from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField from opaque_keys.edx.keys import CourseKey, UsageKey @@ -171,8 +172,44 @@ def submit_completion(self, user, course_key, block_key, aggregation_name, earne 'last_modified': last_modified, }, ) + self.emit_completion_aggregator_logs([obj]) + return obj, is_new + @staticmethod + def emit_completion_aggregator_logs(updated_aggregators): + """ + Emit a tracking log for each element of the list parameter. + + Parameters + ---------- + updated_aggregators: List of Aggregator intances + + """ + for aggregator in updated_aggregators: + event = "progress" if aggregator.percent < 1 else "completion" + event_type = aggregator.aggregation_name + + if event_type not in settings.ALLOWED_COMPLETION_AGGREGATOR_EVENT_TYPES.get(event, {}): + continue + + event_name = f"edx.completion_aggregator.{event}.{event_type}" + + tracker.emit( + event_name, + { + "user_id": aggregator.user_id, + "course_id": str(aggregator.course_key), + "block_id": str(aggregator.block_key), + "modified": aggregator.modified, + "created": aggregator.created, + "earned": aggregator.earned, + "possible": aggregator.possible, + "percent": aggregator.percent, + "type": event_type, + } + ) + def bulk_create_or_update(self, updated_aggregators): """ Update the collection of aggregator object using mysql insert on duplicate update query. @@ -194,6 +231,7 @@ def bulk_create_or_update(self, updated_aggregators): else: aggregation_data = [obj.get_values() for obj in updated_aggregators] cur.executemany(INSERT_OR_UPDATE_AGGREGATOR_QUERY, aggregation_data) + self.emit_completion_aggregator_logs(updated_aggregators) class Aggregator(TimeStampedModel): diff --git a/completion_aggregator/settings/common.py b/completion_aggregator/settings/common.py index 8d2aa479..75466014 100644 --- a/completion_aggregator/settings/common.py +++ b/completion_aggregator/settings/common.py @@ -9,6 +9,23 @@ def plugin_settings(settings): """ Modify the provided settings object with settings specific to this plugin. """ + # Emit feature allows to publish two kind of events progress and completion + # This setting controls which type of event will be published to change the default behavior + # the block type should be removed or added from the progress or completion list. + settings.ALLOWED_COMPLETION_AGGREGATOR_EVENT_TYPES = { + "progress": { + "course", + "chapter", + "sequential", + "vertical", + }, + "completion": { + "course", + "chapter", + "sequential", + "vertical", + } + } settings.COMPLETION_AGGREGATOR_BLOCK_TYPES = { 'course', 'chapter', diff --git a/requirements/base.in b/requirements/base.in index 36fb18c6..8fee866b 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -9,5 +9,6 @@ django-model-utils # Provides TimeStampedModel abstract base class edx-opaque-keys # Provides CourseKey and UsageKey edx-completion edx-toggles +event-tracking six XBlock[django] diff --git a/requirements/base.txt b/requirements/base.txt index a3415760..5f13f786 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -113,7 +113,9 @@ edx-toggles==5.1.0 # -r requirements/base.in # edx-completion event-tracking==2.2.0 - # via edx-completion + # via + # -r requirements/base.in + # edx-completion fs==2.4.16 # via # fs-s3fs diff --git a/test_settings.py b/test_settings.py index bdd4004b..4bd61f7c 100644 --- a/test_settings.py +++ b/test_settings.py @@ -18,6 +18,20 @@ def root(*args): return join(abspath(dirname(__file__)), *args) +ALLOWED_COMPLETION_AGGREGATOR_EVENT_TYPES = { + "progress": [ + "course", + "chapter", + "sequential", + "vertical", + ], + "completion": [ + "course", + "chapter", + "sequential", + "vertical", + ] +} AUTH_USER_MODEL = 'auth.User' CELERY_ALWAYS_EAGER = True COMPLETION_AGGREGATOR_BLOCK_TYPES = {'course', 'chapter', 'sequential'} @@ -53,6 +67,7 @@ def root(*args): 'oauth2_provider', 'waffle', 'test_utils.test_app', + 'eventtracking.django.apps.EventTrackingConfig', ) LOCALE_PATHS = [root('completion_aggregator', 'conf', 'locale')] @@ -81,5 +96,7 @@ def root(*args): ] USE_TZ = True +EVENT_TRACKING_ENABLED = True + # pylint: disable=unused-import,wrong-import-position from test_utils.test_app import celery # isort:skip diff --git a/tests/test_models.py b/tests/test_models.py index e84a57c1..7ab1ce74 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,8 +9,10 @@ import ddt import pytest import six +from mock import patch from opaque_keys.edx.keys import UsageKey +from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test import TestCase @@ -31,6 +33,12 @@ class AggregatorTestCase(TestCase): def setUp(self): super().setUp() self.user = get_user_model().objects.create(username='testuser') + self.tracker_patch = patch('completion_aggregator.models.tracker') + self.tracker_mock = self.tracker_patch.start() + + def tearDown(self): + """Stop patching.""" + self.tracker_mock.stop() def test_submit_completion_with_invalid_user(self): with pytest.raises(TypeError): @@ -43,6 +51,7 @@ def test_submit_completion_with_invalid_user(self): possible=27.0, last_modified=now(), ) + self.tracker_mock.assert_not_called() @ddt.data( # Valid arguments @@ -64,6 +73,7 @@ def test_submit_completion_with_valid_data(self, block_key_obj, aggregate_name, self.assertEqual(obj.earned, earned) self.assertEqual(obj.possible, possible) self.assertEqual(obj.percent, expected_percent) + self.assert_emit_method_called(obj) @ddt.data( # Earned greater than possible @@ -105,6 +115,7 @@ def test_submit_completion_with_exception( ) self.assertEqual(exception_message, str(context_manager.exception)) + self.tracker_mock.assert_not_called() @ddt.data( ( @@ -129,6 +140,7 @@ def test_aggregate_completion_string( f'{six.text_type(block_key_obj)}: {expected_percent}' ) self.assertEqual(six.text_type(obj), expected_string) + self.assert_emit_method_called(obj) @ddt.data( # Changes the value of earned. This does not create a new object. @@ -179,6 +191,7 @@ def test_submit_completion_twice_with_changes( ) self.assertEqual(obj.percent, expected_percent) self.assertTrue(is_new) + self.assert_emit_method_called(obj) new_obj, is_new = Aggregator.objects.submit_completion( user=self.user, @@ -193,6 +206,7 @@ def test_submit_completion_twice_with_changes( self.assertEqual(is_new, is_second_obj_new) if is_second_obj_new: self.assertNotEqual(obj.id, new_obj.id) + self.assert_emit_method_called(new_obj) @ddt.data( (BLOCK_KEY_OBJ, 'course', 0.5, 1, 0.5), @@ -211,3 +225,26 @@ def test_get_values(self, block_key_obj, aggregate_name, earned, possible, expec values = aggregator.get_values() self.assertEqual(values['user'], self.user.id) self.assertEqual(values['percent'], expected_percent) + + def assert_emit_method_called(self, obj): + """Verify that the tracker.emit method was called once with the right values.""" + if obj.aggregation_name not in settings.ALLOWED_COMPLETION_AGGREGATOR_EVENT_TYPES: + return + + event = "progress" if obj.percent < 1 else "completion" + + self.tracker_mock.emit.assert_called_once_with( + f"edx.completion_aggregator.{event}.{obj.aggregation_name}", + { + "user_id": obj.user_id, + "course_id": str(obj.course_key), + "block_id": str(obj.block_key), + "modified": obj.modified, + "created": obj.created, + "earned": obj.earned, + "possible": obj.possible, + "percent": obj.percent, + "type": obj.aggregation_name, + } + ) + self.tracker_mock.emit.reset_mock()