From 8c1bf2cd3a3c320bee85d0a0352de6d70d47eadf Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 3 Sep 2015 16:07:12 -0400 Subject: [PATCH] Add events for tracking when teams are edited. TNL-3190 --- common/djangoapps/util/model_utils.py | 53 ++++++++++++------- .../test/acceptance/tests/lms/test_teams.py | 51 +++++++++++++++++- lms/djangoapps/teams/models.py | 6 +++ lms/djangoapps/teams/tests/test_views.py | 29 +++++++++- lms/djangoapps/teams/views.py | 22 ++++++++ 5 files changed, 141 insertions(+), 20 deletions(-) diff --git a/common/djangoapps/util/model_utils.py b/common/djangoapps/util/model_utils.py index 9ba07f04b1..e0a03bc690 100644 --- a/common/djangoapps/util/model_utils.py +++ b/common/djangoapps/util/model_utils.py @@ -99,6 +99,35 @@ def emit_field_changed_events(instance, user, db_table, excluded_fields=None, hi del instance._changed_fields +def truncate_fields(old_value, new_value): + """ + Truncates old_value and new_value for analytics event emission if necessary. + + Args: + old_value(obj): the value before the change + new_value(obj): the new value being saved + + Returns: + a dictionary with the following fields: + 'old': the truncated old value + 'new': the truncated new value + 'truncated': the list of fields that have been truncated + """ + # Compute the maximum value length so that two copies can fit into the maximum event size + # in addition to all the other fields recorded. + max_value_length = settings.TRACK_MAX_EVENT / 4 + + serialized_old_value, old_was_truncated = _get_truncated_setting_value(old_value, max_length=max_value_length) + serialized_new_value, new_was_truncated = _get_truncated_setting_value(new_value, max_length=max_value_length) + truncated_values = [] + if old_was_truncated: + truncated_values.append("old") + if new_was_truncated: + truncated_values.append("new") + + return {'old': serialized_old_value, 'new': serialized_new_value, 'truncated': truncated_values} + + def emit_setting_changed_event(user, db_table, setting_name, old_value, new_value): """Emits an event for a change in a setting. @@ -112,27 +141,15 @@ def emit_setting_changed_event(user, db_table, setting_name, old_value, new_valu Returns: None """ - # Compute the maximum value length so that two copies can fit into the maximum event size - # in addition to all the other fields recorded. - max_value_length = settings.TRACK_MAX_EVENT / 4 + truncated_fields = truncate_fields(old_value, new_value) + + truncated_fields['setting'] = setting_name + truncated_fields['user_id'] = user.id + truncated_fields['table'] = db_table - serialized_old_value, old_was_truncated = _get_truncated_setting_value(old_value, max_length=max_value_length) - serialized_new_value, new_was_truncated = _get_truncated_setting_value(new_value, max_length=max_value_length) - truncated_values = [] - if old_was_truncated: - truncated_values.append("old") - if new_was_truncated: - truncated_values.append("new") tracker.emit( USER_SETTINGS_CHANGED_EVENT_NAME, - { - "setting": setting_name, - "old": serialized_old_value, - "new": serialized_new_value, - "truncated": truncated_values, - "user_id": user.id, - "table": db_table, - } + truncated_fields ) diff --git a/common/test/acceptance/tests/lms/test_teams.py b/common/test/acceptance/tests/lms/test_teams.py index ffb67ae157..4256876e74 100644 --- a/common/test/acceptance/tests/lms/test_teams.py +++ b/common/test/acceptance/tests/lms/test_teams.py @@ -1017,6 +1017,7 @@ class EditTeamTest(TeamFormActions): Then I should see the Edit Team button And When I click edit team button Then I should see the edit team page + And an analytics event should be fired When I edit all the fields with appropriate data And I click Update button Then I should see the page for my team with updated data @@ -1030,7 +1031,55 @@ class EditTeamTest(TeamFormActions): self.verify_and_navigate_to_edit_team_page() self.fill_create_or_edit_form() - self.create_or_edit_team_page.submit_form() + + expected_events = [ + { + 'event_type': 'edx.team.changed', + 'event': { + 'course_id': self.course_id, + 'team_id': self.team['id'], + 'field': 'country', + 'old': 'AF', + 'new': 'PK', + 'truncated': [], + } + }, + { + 'event_type': 'edx.team.changed', + 'event': { + 'course_id': self.course_id, + 'team_id': self.team['id'], + 'field': 'name', + 'old': self.team['name'], + 'new': self.TEAMS_NAME, + 'truncated': [], + } + }, + { + 'event_type': 'edx.team.changed', + 'event': { + 'course_id': self.course_id, + 'team_id': self.team['id'], + 'field': 'language', + 'old': 'aa', + 'new': 'en', + 'truncated': [], + } + }, + { + 'event_type': 'edx.team.changed', + 'event': { + 'course_id': self.course_id, + 'team_id': self.team['id'], + 'field': 'description', + 'old': self.team['description'], + 'new': self.TEAM_DESCRIPTION, + 'truncated': [], + } + }, + ] + with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events): + self.create_or_edit_team_page.submit_form() self.team_page.wait_for_page() diff --git a/lms/djangoapps/teams/models.py b/lms/djangoapps/teams/models.py index e94e76da26..accedf6b56 100644 --- a/lms/djangoapps/teams/models.py +++ b/lms/djangoapps/teams/models.py @@ -4,6 +4,7 @@ from datetime import datetime from uuid import uuid4 import pytz from datetime import datetime +from model_utils import FieldTracker from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User @@ -89,6 +90,11 @@ class CourseTeam(models.Model): users = models.ManyToManyField(User, db_index=True, related_name='teams', through='CourseTeamMembership') team_size = models.IntegerField(default=0, db_index=True) # indexed for ordering + field_tracker = FieldTracker() + + # Don't emit changed events when these fields change. + FIELD_BLACKLIST = ['last_activity_at', 'team_size'] + @classmethod def create(cls, name, course_id, description, topic_id=None, country=None, language=None): """Create a complete CourseTeam object. diff --git a/lms/djangoapps/teams/tests/test_views.py b/lms/djangoapps/teams/tests/test_views.py index 07975309d7..550bc47d08 100644 --- a/lms/djangoapps/teams/tests/test_views.py +++ b/lms/djangoapps/teams/tests/test_views.py @@ -743,9 +743,12 @@ class TestDetailTeamAPI(TeamAPITestCase): @ddt.ddt -class TestUpdateTeamAPI(TeamAPITestCase): +class TestUpdateTeamAPI(EventTestMixin, TeamAPITestCase): """Test cases for the team update endpoint.""" + def setUp(self): # pylint: disable=arguments-differ + super(TestUpdateTeamAPI, self).setUp('teams.views.tracker') + @ddt.data( (None, 401), ('student_inactive', 401), @@ -757,9 +760,19 @@ class TestUpdateTeamAPI(TeamAPITestCase): ) @ddt.unpack def test_access(self, user, status): + prev_name = self.solar_team.name team = self.patch_team_detail(self.solar_team.team_id, status, {'name': 'foo'}, user=user) if status == 200: self.assertEquals(team['name'], 'foo') + self.assert_event_emitted( + 'edx.team.changed', + team_id=self.solar_team.team_id, + course_id=unicode(self.solar_team.course_id), + truncated=[], + field='name', + old=prev_name, + new='foo' + ) @ddt.data( (None, 401), @@ -787,8 +800,22 @@ class TestUpdateTeamAPI(TeamAPITestCase): @ddt.data(('country', 'US'), ('language', 'en'), ('foo', 'bar')) @ddt.unpack def test_good_requests(self, key, value): + if hasattr(self.solar_team, key): + prev_value = getattr(self.solar_team, key) + self.patch_team_detail(self.solar_team.team_id, 200, {key: value}, user='staff') + if hasattr(self.solar_team, key): + self.assert_event_emitted( + 'edx.team.changed', + team_id=self.solar_team.team_id, + course_id=unicode(self.solar_team.course_id), + truncated=[], + field=key, + old=prev_value, + new=value + ) + def test_does_not_exist(self): self.patch_team_detail('no_such_team', 404, user='staff') diff --git a/lms/djangoapps/teams/views.py b/lms/djangoapps/teams/views.py index 4ceda4e9fa..24d09248ef 100644 --- a/lms/djangoapps/teams/views.py +++ b/lms/djangoapps/teams/views.py @@ -16,6 +16,8 @@ from rest_framework.authentication import ( from rest_framework import status from rest_framework import permissions from django.db.models import Count +from django.db.models.signals import post_save +from django.dispatch import receiver from django.contrib.auth.models import User from django_countries import countries from django.utils.translation import ugettext as _ @@ -40,6 +42,7 @@ from student.models import CourseEnrollment, CourseAccessRole from student.roles import CourseStaffRole from django_comment_client.utils import has_discussion_privileges from teams import is_feature_enabled +from util.model_utils import truncate_fields from .models import CourseTeam, CourseTeamMembership from .serializers import ( CourseTeamSerializer, @@ -59,6 +62,25 @@ TOPICS_PER_PAGE = 12 MAXIMUM_SEARCH_SIZE = 100000 +@receiver(post_save, sender=CourseTeam) +def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unused-argument + """ Emits signal after the team is saved. """ + changed_fields = instance.field_tracker.changed() + # Don't emit events when we are first creating the team. + if not kwargs['created']: + for field in changed_fields: + if field not in instance.FIELD_BLACKLIST: + truncated_fields = truncate_fields(unicode(changed_fields[field]), unicode(getattr(instance, field))) + truncated_fields['team_id'] = instance.team_id + truncated_fields['course_id'] = unicode(instance.course_id) + truncated_fields['field'] = field + + tracker.emit( + 'edx.team.changed', + truncated_fields + ) + + class TeamsDashboardView(View): """ View methods related to the teams dashboard.