From 5c0fd63f13470b8890423c8b7b3f112ac66882e7 Mon Sep 17 00:00:00 2001 From: Jansen Kantor <jkantor@edx.org> Date: Mon, 22 Jun 2020 14:25:05 -0400 Subject: [PATCH] emit an event when removing users from teams via csv (#24271) --- lms/djangoapps/teams/csv.py | 38 +++-- lms/djangoapps/teams/tests/test_csv.py | 200 +++++++++++++++++++------ 2 files changed, 177 insertions(+), 61 deletions(-) diff --git a/lms/djangoapps/teams/csv.py b/lms/djangoapps/teams/csv.py index 0143253d21c..43a04d3dd20 100644 --- a/lms/djangoapps/teams/csv.py +++ b/lms/djangoapps/teams/csv.py @@ -436,12 +436,7 @@ class TeamMembershipImportManager(object): if row[ts_id] is None: # remove this student from the teamset try: - membership = CourseTeamMembership.objects.get( - user_id=row['user'].id, - team__topic_id=ts_id, - team__course_id=self.course.id - ) - membership.delete() + self._remove_user_from_teamset_and_emit_signal(row['user'].id, ts_id, self.course.id) except CourseTeamMembership.DoesNotExist: pass else: @@ -450,12 +445,7 @@ class TeamMembershipImportManager(object): current_user_teams_name = self.existing_course_team_memberships[row['user'].id, ts_id].name if current_user_teams_name != row[ts_id]: try: - membership = CourseTeamMembership.objects.get( - user_id=row['user'].id, - team__topic_id=ts_id, - team__course_id=self.course.id - ) - membership.delete() + self._remove_user_from_teamset_and_emit_signal(row['user'].id, ts_id, self.course.id) del self.existing_course_team_memberships[row['user'].id, ts_id] self.user_ids_by_teamset_id[ts_id].remove(row['user'].id) except CourseTeamMembership.DoesNotExist: @@ -465,6 +455,30 @@ class TeamMembershipImportManager(object): # to readd the user, null out the team name row[ts_id] = None + def _remove_user_from_teamset_and_emit_signal(self, user_id, ts_id, course_id): + """ + If a team membership exists for the specified user, in the specified course and teamset, delete it. + This removes the user from the team. + Then, emit an event. + + If the membership doesn't exist, don't emit the event and instead raise CourseTeamMembership.DoesNotExist + """ + membership = CourseTeamMembership.objects.select_related('team').get( + user_id=user_id, + team__topic_id=ts_id, + team__course_id=course_id + ) + membership.delete() + emit_team_event( + 'edx.team.learner_removed', + course_id, + { + 'team_id': membership.team.team_id, + 'user_id': membership.user_id, + 'remove_method': 'team_csv_import' + } + ) + def add_error_and_check_if_max_exceeded(self, error_message): """ Adds an error to the error collection. diff --git a/lms/djangoapps/teams/tests/test_csv.py b/lms/djangoapps/teams/tests/test_csv.py index 91040f25cac..5ec70546b11 100644 --- a/lms/djangoapps/teams/tests/test_csv.py +++ b/lms/djangoapps/teams/tests/test_csv.py @@ -6,16 +6,70 @@ from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollment from lms.djangoapps.teams import csv from lms.djangoapps.teams.models import CourseTeam, CourseTeamMembership from lms.djangoapps.teams.tests.factories import CourseTeamFactory +from openedx.core.lib.teams_config import TeamsConfig from student.tests.factories import CourseEnrollmentFactory, UserFactory +from util.testing import EventTestMixin from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from openedx.core.lib.teams_config import TeamsConfig + + +def csv_import(course, csv_dict_rows): + """ + Create a csv file with the given contents and pass it to the csv import manager to test the full + csv import flow + + Parameters: + - csv_dict_rows: list of dicts, representing a row of the csv file + """ + # initialize import manager + import_manager = csv.TeamMembershipImportManager(course) + import_manager.teamset_ids = {ts.teamset_id for ts in course.teamsets} + + with BytesIO() as mock_csv_file: + with TextIOWrapper(mock_csv_file, write_through=True) as text_wrapper: + # pylint: disable=protected-access + header_fields = csv._get_team_membership_csv_headers(course) + csv_writer = DictWriter(text_wrapper, fieldnames=header_fields) + csv_writer.writeheader() + csv_writer.writerows(csv_dict_rows) + mock_csv_file.seek(0) + import_manager.set_team_membership_from_csv(mock_csv_file) + + +def csv_export(course): + """ + Call csv.load_team_membership_csv for the given course, and return the result. + The result is returned in the form of a dictionary keyed by the 'user' identifiers for each row, + mapping to the full parsed dictionary for that row of the csv. + + Returns: DictReader for the returned csv file + """ + with StringIO() as read_buf: + csv.load_team_membership_csv(course, read_buf) + read_buf.seek(0) + return DictReader(read_buf.readlines()) + + +def _user_keyed_dict(reader): + """ create a dict of the rows of the csv, keyed by the "user" value """ + return {row['user']: row for row in reader} + + +def _csv_dict_row(user, mode, **kwargs): + """ + Convenience method to create dicts to pass to csv_import + """ + csv_dict_row = dict(kwargs) + csv_dict_row['user'] = user + csv_dict_row['mode'] = mode + return csv_dict_row class TeamMembershipCsvTests(SharedModuleStoreTestCase): """ Tests for functionality related to the team membership csv report """ @classmethod def setUpClass(cls): + # pylint: disable=no-member super(TeamMembershipCsvTests, cls).setUpClass() teams_config = TeamsConfig({ 'team_sets': [ @@ -69,10 +123,6 @@ class TeamMembershipCsvTests(SharedModuleStoreTestCase): team3_2.add_user(user4) - def setUp(self): - super(TeamMembershipCsvTests, self).setUp() - self.buf = StringIO() - def test_get_headers(self): # pylint: disable=protected-access headers = csv._get_team_membership_csv_headers(self.course) @@ -122,18 +172,66 @@ class TeamMembershipCsvTests(SharedModuleStoreTestCase): self.assertEqual(user_row.get('teamset_3'), expected_teamset_3_team) def test_load_team_membership_csv(self): - expected_csv_output = ('user,mode,teamset_1,teamset_2,teamset_3,teamset_4\r\n' - 'user1,audit,team_1_1,team_2_2,team_3_1,\r\n' - 'user2,verified,team_1_1,team_2_2,team_3_1,\r\n' - 'user3,honors,,team_2_1,team_3_1,\r\n' - 'user4,masters,,,team_3_2,\r\n' - 'user5,masters,,,,\r\n') - csv.load_team_membership_csv(self.course, self.buf) - self.assertEqual(expected_csv_output, self.buf.getvalue()) + expected_csv_headers = ['user', 'mode', 'teamset_1', 'teamset_2', 'teamset_3', 'teamset_4'] + expected_data = {} + expected_data['user1'] = _csv_dict_row( + 'user1', + 'audit', + teamset_1='team_1_1', + teamset_2='team_2_2', + teamset_3='team_3_1', + ) + expected_data['user2'] = _csv_dict_row( + 'user2', + 'verified', + teamset_1='team_1_1', + teamset_2='team_2_2', + teamset_3='team_3_1', + ) + expected_data['user3'] = _csv_dict_row('user3', 'honors', teamset_2='team_2_1', teamset_3='team_3_1') + expected_data['user4'] = _csv_dict_row('user4', 'masters', teamset_3='team_3_2') + expected_data['user5'] = _csv_dict_row('user5', 'masters') + self._add_blanks_to_expected_data(expected_data, expected_csv_headers) + + reader = csv_export(self.course) + self.assertEqual(expected_csv_headers, reader.fieldnames) + self.assertDictEqual(expected_data, _user_keyed_dict(reader)) + + def _add_blanks_to_expected_data(self, expected_data, headers): + """ Helper method to fill in the "blanks" in test data """ + for user in expected_data: + user_row = expected_data[user] + for header in headers: + if header not in user_row: + user_row[header] = '' + + +class TeamMembershipEventTestMixin(EventTestMixin): + """ Mixin to provide functionality for testing signals emitted by csv code """ + + def setUp(self): + # pylint: disable=arguments-differ + super().setUp('lms.djangoapps.teams.utils.tracker') + + def assert_learner_added_emitted(self, team_id, user_id): + self.assert_event_emitted( + 'edx.team.learner_added', + team_id=team_id, + user_id=user_id, + add_method='team_csv_import' + ) + + def assert_learner_removed_emitted(self, team_id, user_id): + self.assert_event_emitted( + 'edx.team.learner_removed', + team_id=team_id, + user_id=user_id, + remove_method='team_csv_import' + ) # pylint: disable=no-member -class TeamMembershipImportManagerTests(SharedModuleStoreTestCase): +class TeamMembershipImportManagerTests(TeamMembershipEventTestMixin, SharedModuleStoreTestCase): """ Tests for TeamMembershipImportManager """ @classmethod def setUpClass(cls): @@ -163,7 +261,9 @@ class TeamMembershipImportManagerTests(SharedModuleStoreTestCase): } self.import_manager.add_user_to_team(row) - self.assertTrue(CourseTeam.objects.get(team_id__startswith='new_protected_team').organization_protected) + team = CourseTeam.objects.get(team_id__startswith='new_protected_team') + self.assertTrue(team.organization_protected) + self.assert_learner_added_emitted(team.team_id, masters_learner.id) def test_add_user_to_new_unprotected_team(self): """Adding a non-masters learner to a new team should create a team with no organization protected status""" @@ -176,7 +276,9 @@ class TeamMembershipImportManagerTests(SharedModuleStoreTestCase): } self.import_manager.add_user_to_team(row) - self.assertFalse(CourseTeam.objects.get(team_id__startswith='new_unprotected_team').organization_protected) + team = CourseTeam.objects.get(team_id__startswith='new_unprotected_team') + self.assertFalse(team.organization_protected) + self.assert_learner_added_emitted(team.team_id, audit_learner.id) def test_team_removals_are_scoped_correctly(self): """ Team memberships should not search across topics in different courses """ @@ -207,9 +309,29 @@ class TeamMembershipImportManagerTests(SharedModuleStoreTestCase): # They are successfully removed from the team self.assertFalse(CourseTeamMembership.is_user_on_team(audit_learner, course_1_team)) + self.assert_learner_removed_emitted(course_1_team.team_id, audit_learner.id) + + def test_user_moved_to_another_team(self): + """ We should be able to move a user from one team to another """ + # Create a learner, enroll in course + audit_learner = UserFactory.create(username='audit_learner') + CourseEnrollmentFactory.create(user=audit_learner, course_id=self.course.id, mode='audit') + # Make two teams in the same teamset, enroll the user in one + team_1 = CourseTeamFactory(course_id=self.course.id, name='test_team_1', topic_id='teamset_1') + team_2 = CourseTeamFactory(course_id=self.course.id, name='test_team_2', topic_id='teamset_1') + team_1.add_user(audit_learner) + + csv_row = _csv_dict_row(audit_learner, 'audit', teamset_1=team_2.name) + csv_import(self.course, [csv_row]) + + self.assertFalse(CourseTeamMembership.is_user_on_team(audit_learner, team_1)) + self.assertTrue(CourseTeamMembership.is_user_on_team(audit_learner, team_2)) + self.assert_learner_removed_emitted(team_1.team_id, audit_learner.id) + self.assert_learner_added_emitted(team_2.team_id, audit_learner.id) -class ExternalKeyCsvTests(SharedModuleStoreTestCase): + +class ExternalKeyCsvTests(TeamMembershipEventTestMixin, SharedModuleStoreTestCase): """ Tests for functionality related to external_user_keys""" @classmethod @@ -251,10 +373,6 @@ class ExternalKeyCsvTests(SharedModuleStoreTestCase): cls.user_in_program_not_enrolled_through_program, connect_enrollments=False ) - # initialize import manager - cls.import_manager = csv.TeamMembershipImportManager(cls.course) - cls.import_manager.teamset_ids = {ts.teamset_id for ts in cls.course.teamsets} - @classmethod def add_user_to_course_program_team( cls, user, add_to_team=True, enroll_in_program=True, connect_enrollments=True, external_user_key=None @@ -289,33 +407,20 @@ class ExternalKeyCsvTests(SharedModuleStoreTestCase): self.add_user_to_course_program_team(new_user, add_to_team=False, external_user_key=new_ext_key) self.assert_user_not_on_team(new_user) - with BytesIO() as mock_csv_file: - with TextIOWrapper(mock_csv_file, write_through=True) as text_wrapper: - # Create the fake csv file - csv_writer = DictWriter(text_wrapper, fieldnames=self.header_fields) - csv_writer.writeheader() - # Add the new user to the team via CSV upload, identified by their external key - csv_writer.writerow({'user': new_ext_key, 'mode': 'audit', self.teamset_id: self.team.name}) - # We need to seek to the beginning of the file so the csv import manager can read it - mock_csv_file.seek(0) - #After processing the file, the user should be on the team - self.import_manager.set_team_membership_from_csv(mock_csv_file) - + csv_import_row = _csv_dict_row(new_ext_key, 'audit', teamset_id=self.team.name) + csv_import(self.course, [csv_import_row]) self.assert_user_on_team(new_user) + self.assert_learner_added_emitted(self.team.team_id, new_user.id) def test_lookup_team_membership_data(self): with self.assertNumQueries(3): # pylint: disable=protected-access data = csv._lookup_team_membership_data(self.course) - self._assert_test_users_on_team(data) + self._assert_test_users_on_team(_user_keyed_dict(data)) def test_get_csv(self): - with StringIO() as read_buf: - csv.load_team_membership_csv(self.course, read_buf) - read_buf.seek(0) - reader = DictReader(read_buf) - team_memberships = list(reader) - self._assert_test_users_on_team(team_memberships) + reader = csv_export(self.course) + self._assert_test_users_on_team(_user_keyed_dict(reader)) def _assert_test_users_on_team(self, data): """ @@ -323,16 +428,13 @@ class ExternalKeyCsvTests(SharedModuleStoreTestCase): and user_in_program should be identified by their external_user_key """ self.assertEqual(len(data), 4) - self.assertEqual( - {user_row['user'] for user_row in data}, - { + expected_data = { + user_identifier: _csv_dict_row(user_identifier, 'audit', teamset_id=self.team.name) + for user_identifier in [ self.user_no_program.username, self.user_in_program_no_external_id.username, self.user_in_program_not_enrolled_through_program.username, self.external_user_key - } - ) - for user_row in data: - self.assertEqual(len(user_row), 3) - self.assertEqual(user_row['mode'], 'audit') - self.assertEqual(user_row[self.teamset_id], self.team.name) + ] + } + self.assertDictEqual(expected_data, data) -- GitLab