diff --git a/lms/djangoapps/teams/csv.py b/lms/djangoapps/teams/csv.py index bebc66e2c6..aa8e11b764 100644 --- a/lms/djangoapps/teams/csv.py +++ b/lms/djangoapps/teams/csv.py @@ -7,7 +7,11 @@ from collections import Counter from django.contrib.auth.models import User -from lms.djangoapps.teams.api import OrganizationProtectionStatus, user_organization_protection_status +from lms.djangoapps.teams.api import ( + OrganizationProtectionStatus, + user_organization_protection_status, + ORGANIZATION_PROTECTED_MODES +) from lms.djangoapps.teams.models import CourseTeam, CourseTeamMembership from lms.djangoapps.program_enrollments.models import ProgramEnrollment from student.models import CourseEnrollment @@ -110,8 +114,10 @@ class TeamMembershipImportManager(object): self.existing_course_team_memberships = {} self.existing_course_teams = {} self.user_count_by_team = Counter() + self.user_enrollment_by_team = {} self.user_to_remove_by_team = Counter() self.number_of_learners_assigned = 0 + self.user_to_actual_enrollment_mode = {} @property def import_succeeded(self): @@ -240,7 +246,7 @@ class TeamMembershipImportManager(object): if actual_enrollment_mode != supplied_enrollment.strip(): self.validation_errors.append('User ' + user.username + ' enrollment mismatch.') return False - + self.user_to_actual_enrollment_mode[user.id] = actual_enrollment_mode return True def is_username_unique(self, username, usernames_found_so_far): @@ -294,6 +300,8 @@ class TeamMembershipImportManager(object): pass continue try: + if not self.validate_compatible_enrollment_modes(user, team_name, teamset_id): + return False # checks for a team inside a specific team set. This way team names can be duplicated across # teamsets team = self.existing_course_teams[(team_name, teamset_id)] @@ -306,6 +314,38 @@ class TeamMembershipImportManager(object): return False return True + def validate_compatible_enrollment_modes(self, user, team_name, teamset_id): + """ + Validates that only students enrolled in a masters track are on a single team. Disallows mixing of masters + with other enrollment modes on a single team. + """ + if(teamset_id, team_name) not in self.user_enrollment_by_team: + self.user_enrollment_by_team[teamset_id, team_name] = set() + self.user_enrollment_by_team[teamset_id, team_name].add(self.user_to_actual_enrollment_mode[user.id]) + if self.is_FERPA_bubble_breached(teamset_id, team_name): + error_message = \ + 'Team {} cannot have Master’s track users mixed with users in other tracks.'.format(team_name) + self.add_error_and_check_if_max_exceeded(error_message) + return False + return True + + def is_FERPA_bubble_breached(self, teamset_id, team_name): + """ + Ensures that FERPA bubble is not breached. + Checks that we are not trying to violate FERPA proctection by mixing masters + track students with other enrollment tracks. + """ + + team_enrollment_modes = self.user_enrollment_by_team[teamset_id, team_name] + protected_modes = set(ORGANIZATION_PROTECTED_MODES) + + if team_enrollment_modes.isdisjoint(protected_modes): + return False + elif team_enrollment_modes.issubset(protected_modes): + return False + else: + return True + def validate_proposed_team_size_wont_exceed_maximum(self, team_name, teamset_id): """ Validates that the number of users we want to add to a team won't exceed maximum team size. diff --git a/lms/djangoapps/teams/tests/test_views.py b/lms/djangoapps/teams/tests/test_views.py index 76f5c354c6..6be584aaab 100644 --- a/lms/djangoapps/teams/tests/test_views.py +++ b/lms/djangoapps/teams/tests/test_views.py @@ -2722,6 +2722,30 @@ class TestBulkMembershipManagement(TeamAPITestCase): data={'csv': csv_file}, user='staff' ) + def test_upload_invalid_multiple_student_enrollment_mismatch(self): + audit_username = 'audit_user' + masters_username_a = 'masters_a' + masters_username_b = 'masters_b' + self.create_and_enroll_student(username=audit_username, mode=CourseMode.AUDIT) + self.create_and_enroll_student(username=masters_username_a, mode=CourseMode.MASTERS) + self.create_and_enroll_student(username=masters_username_b, mode=CourseMode.MASTERS) + + csv_content = 'user,mode,topic_1' + '\n' + csv_content += '{},audit,team wind power'.format(audit_username) + '\n' + csv_content += '{},masters,team wind power'.format(masters_username_a) + '\n' + csv_content += '{},masters,team wind power'.format(masters_username_b) + '\n' + csv_file = SimpleUploadedFile('test_file.csv', csv_content.encode('utf8'), content_type='text/csv') + self.client.login(username=self.users['course_staff'].username, password=self.users['course_staff'].password) + response = self.make_call(reverse( + 'team_membership_bulk_management', + args=[self.good_course_id]), + 400, method='post', + data={'csv': csv_file}, user='staff' + ) + response_text = json.loads(response.content.decode('utf-8')) + expected_error = 'Team team wind power cannot have Master’s track users mixed with users in other tracks.' + self.assertEqual(response_text['errors'][0], expected_error) + def test_upload_learners_exceed_max_team_size(self): csv_content = 'user,mode,topic_0,topic_1' + '\n' team1 = 'team wind power'