""" Tests for the teams API at the HTTP request level. """ import json import unittest from datetime import datetime from unittest.mock import patch from uuid import UUID import ddt import pytz from dateutil import parser from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models.signals import post_save from django.urls import reverse from django.utils import translation from elasticsearch.exceptions import ConnectionError # lint-amnesty, pylint: disable=redefined-builtin from rest_framework.test import APIClient, APITestCase from search.search_engine_base import SearchEngine from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory from common.djangoapps.util.testing import EventTestMixin from common.test.utils import skip_signal from lms.djangoapps.courseware.tests.factories import StaffFactory from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA, Role from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.lib.teams_config import TeamsConfig from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from ..models import CourseTeamMembership from ..search_indexes import CourseTeam, CourseTeamIndexer, course_team_post_save_callback from .factories import LAST_ACTIVITY_AT, CourseTeamFactory @ddt.ddt class TestDashboard(SharedModuleStoreTestCase): """Tests for the Teams dashboard.""" test_password = "test" NUM_TOPICS = 10 @classmethod def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create( teams_configuration=TeamsConfig({ "max_team_size": 10, "topics": [ { "name": f"Topic {topic_id}", "id": topic_id, "description": f"Description for topic {topic_id}" } for topic_id in range(cls.NUM_TOPICS) ] }) ) def setUp(self): """ Set up tests """ super().setUp() # will be assigned to self.client by default self.user = UserFactory.create(password=self.test_password) self.teams_url = reverse('teams_dashboard', args=[self.course.id]) def test_anonymous(self): """Verifies that an anonymous client cannot access the team dashboard, and is redirected to the login page.""" anonymous_client = APIClient() response = anonymous_client.get(self.teams_url) redirect_url = f'{settings.LOGIN_URL}?next={self.teams_url}' self.assertRedirects(response, redirect_url) def test_not_enrolled_not_staff(self): """ Verifies that a student who is not enrolled cannot access the team dashboard. """ self.client.login(username=self.user.username, password=self.test_password) response = self.client.get(self.teams_url) assert 404 == response.status_code def test_not_enrolled_staff(self): """ Verifies that a user with global access who is not enrolled in the course can access the team dashboard. """ staff_user = UserFactory(is_staff=True, password=self.test_password) staff_client = APIClient() staff_client.login(username=staff_user.username, password=self.test_password) response = staff_client.get(self.teams_url) self.assertContains(response, "TeamsTabFactory", status_code=200) def test_enrolled_not_staff(self): """ Verifies that a user without global access who is enrolled in the course can access the team dashboard. """ CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) self.client.login(username=self.user.username, password=self.test_password) response = self.client.get(self.teams_url) self.assertContains(response, "TeamsTabFactory", status_code=200) def test_enrolled_teams_not_enabled(self): """ Verifies that a user without global access who is enrolled in the course cannot access the team dashboard if the teams feature is not enabled. """ course = CourseFactory.create() teams_url = reverse('teams_dashboard', args=[course.id]) CourseEnrollmentFactory.create(user=self.user, course_id=course.id) self.client.login(username=self.user.username, password=self.test_password) response = self.client.get(teams_url) assert 404 == response.status_code @unittest.skip("Fix this - getting unreliable query counts") def test_query_counts(self): # Enroll in the course and log in CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) self.client.login(username=self.user.username, password=self.test_password) # Check the query count on the dashboard with no teams with self.assertNumQueries(18): self.client.get(self.teams_url) # Create some teams for topic_id in range(self.NUM_TOPICS): team = CourseTeamFactory.create( name=f"Team for topic {topic_id}", course_id=self.course.id, topic_id=topic_id, ) # Add the user to the last team team.add_user(self.user) # Check the query count on the dashboard again with self.assertNumQueries(24): self.client.get(self.teams_url) def test_bad_course_id(self): """ Verifies expected behavior when course_id does not reference an existing course or is invalid. """ bad_org = "badorgxxx" bad_team_url = self.teams_url.replace(self.course.id.org, bad_org) CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) self.client.login(username=self.user.username, password=self.test_password) response = self.client.get(bad_team_url) assert 404 == response.status_code bad_team_url = bad_team_url.replace(bad_org, "invalid/course/id") response = self.client.get(bad_team_url) assert 404 == response.status_code def get_user_course_specific_teams_list(self): """Gets the list of user course specific teams.""" # Create a course two course_two = CourseFactory.create( teams_configuration=TeamsConfig({ "max_team_size": 1, "topics": [ { "name": "Test topic for course two", "id": 1, "description": "Description for test topic for course two." } ] }) ) # Login and enroll user in both course course self.client.login(username=self.user.username, password=self.test_password) CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) CourseEnrollmentFactory.create(user=self.user, course_id=course_two.id) # Create teams in both courses course_one_team = CourseTeamFactory.create(name="Course one team", course_id=self.course.id, topic_id=1) course_two_team = CourseTeamFactory.create(name="Course two team", course_id=course_two.id, topic_id=1) # pylint: disable=unused-variable # Check that initially list of user teams in course one is empty course_one_teams_url = reverse('teams_dashboard', args=[self.course.id]) response = self.client.get(course_one_teams_url) self.assertContains(response, '"teams": {"count": 0') # Add user to a course one team course_one_team.add_user(self.user) # Check that list of user teams in course one is not empty, it is one now response = self.client.get(course_one_teams_url) self.assertContains(response, '"teams": {"count": 1') # Check that list of user teams in course two is still empty course_two_teams_url = reverse('teams_dashboard', args=[course_two.id]) response = self.client.get(course_two_teams_url) self.assertContains(response, '"teams": {"count": 0') @ddt.unpack @ddt.data( (True, False, False), (False, True, False), (False, False, True), (True, True, True), (False, True, True), ) def test_teamset_counts(self, has_open, has_private, has_public): topics = [] if has_open: topics.append({ "name": "test topic 1", "id": 1, "description": "Desc1", "type": "open" }) if has_private: topics.append({ "name": "test topic 2", "id": 2, "description": "Desc2", "type": "private_managed" }) if has_public: topics.append({ "name": "test topic 3", "id": 3, "description": "Desc3", "type": "public_managed" }) course = CourseFactory.create( teams_configuration=TeamsConfig({"topics": topics}) ) teams_url = reverse('teams_dashboard', args=[course.id]) CourseEnrollmentFactory.create(user=self.user, course_id=course.id) self.client.login(username=self.user.username, password=self.test_password) response = self.client.get(teams_url) expected_has_open = "hasOpenTopic: " + "true" if has_open else "false" expected_has_public = "hasPublicManagedTopic: " + "true" if has_public else "false" self.assertContains(response, expected_has_open) self.assertContains(response, expected_has_public) @ddt.unpack @ddt.data( (True, False, False), (False, True, False), (False, False, True), (True, True, True), (False, True, True), ) def test_has_managed_topic(self, has_open, has_private, has_public): topics = [] if has_open: topics.append({ "name": "test topic 1", "id": 1, "description": "Desc1", "type": "open" }) if has_private: topics.append({ "name": "test topic 2", "id": 2, "description": "Desc2", "type": "private_managed" }) if has_public: topics.append({ "name": "test topic 3", "id": 3, "description": "Desc3", "type": "public_managed" }) # Given a staff user browsing the teams tab course = CourseFactory.create( teams_configuration=TeamsConfig({"topics": topics}) ) teams_url = reverse('teams_dashboard', args=[course.id]) staff_user = UserFactory(is_staff=True, password=self.test_password) staff_client = APIClient() staff_client.login(username=staff_user.username, password=self.test_password) # When I browse to the team tab response = staff_client.get(teams_url) # Then "hasManagedTopic" (which is used to show the "Manage" tab) # is shown if there are managed team-sets expected_has_managed = "hasManagedTopic: " + "true" if has_public or has_private else "false" self.assertContains(response, expected_has_managed) class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase): """Base class for Team API test cases.""" test_password = 'password' @classmethod def setUpClass(cls): # pylint: disable=super-method-not-called with super().setUpClassAndTestData(): base_topics = [{ 'id': f'topic_{i}', 'name': name, 'description': f'Description for topic {i}.', 'max_team_size': 3 } for i, name in enumerate(['Sólar power', 'Wind Power', 'Nuclear Power', 'Coal Power'])] base_topics.append( { 'id': 'private_topic_1_id', 'name': 'private_topic_1_name', 'description': 'Description for topic private topic 1.', 'type': 'private_managed' } ) base_topics.append( { 'id': 'private_topic_2_id', 'name': 'private_topic_2_name', 'description': 'Description for topic private topic 2.', 'type': 'private_managed' } ) base_topics.append( { 'id': 'private_topic_no_teams', 'name': 'private_topic_no_teams_name', 'description': 'Description for topic private_topic_no_teams.', 'type': 'private_managed' } ) teams_configuration_1 = TeamsConfig({ 'topics': base_topics, 'max_team_size': 5 }) cls.test_course_1 = CourseFactory.create( org='TestX', course='TS101', display_name='Test Course', teams_configuration=teams_configuration_1 ) teams_configuration_2 = TeamsConfig({ 'topics': [ { 'id': 'topic_5', 'name': 'Other Interests', 'description': 'Description for topic 5.' }, { 'id': 'topic_6', 'name': 'Public Profiles', 'description': 'Description for topic 6.' }, { 'id': 'Topic_6.5', 'name': 'Test Accessibility Topic', 'description': 'Description for Topic_6.5' }, ], 'max_team_size': 1 }) cls.test_course_2 = CourseFactory.create( org='MIT', course='6.002x', display_name='Circuits', teams_configuration=teams_configuration_2 ) @classmethod def setUpTestData(cls): super().setUpTestData() cls.topics_count = 6 cls.users = { 'staff': AdminFactory.create(password=cls.test_password), 'course_staff': StaffFactory.create(course_key=cls.test_course_1.id, password=cls.test_password), 'admin': AdminFactory.create(password=cls.test_password) } cls.create_and_enroll_student(username='student_enrolled') cls.create_and_enroll_student(username='student_on_team_1_private_set_1', mode=CourseMode.MASTERS) cls.create_and_enroll_student(username='student_on_team_2_private_set_1', mode=CourseMode.MASTERS) cls.create_and_enroll_student(username='student_not_member_of_private_teams', mode=CourseMode.MASTERS) cls.create_and_enroll_student(username='student_enrolled_not_on_team') cls.create_and_enroll_student(username='student_unenrolled', courses=[]) # Make this student a community TA. cls.create_and_enroll_student(username='community_ta') seed_permissions_roles(cls.test_course_1.id) community_ta_role = Role.objects.get(name=FORUM_ROLE_COMMUNITY_TA, course_id=cls.test_course_1.id) community_ta_role.users.add(cls.users['community_ta']) # This student is enrolled in both test courses and is a member of a team in each course, but is not on the # same team as student_enrolled. cls.create_and_enroll_student( courses=[cls.test_course_1, cls.test_course_2], username='student_enrolled_both_courses_other_team' ) # Make this student have a public profile cls.create_and_enroll_student( courses=[cls.test_course_2], username='student_enrolled_public_profile' ) profile = cls.users['student_enrolled_public_profile'].profile profile.year_of_birth = 1970 profile.save() # This student is enrolled in the other course, but not yet a member of a team. This is to allow # course_2 to use a max_team_size of 1 without breaking other tests on course_1 cls.create_and_enroll_student( courses=[cls.test_course_2], username='student_enrolled_other_course_not_on_team' ) # This is a Masters student who should be in the organization protected bubble cls.create_and_enroll_student( courses=[cls.test_course_1, cls.test_course_2], username='student_masters', mode=CourseMode.MASTERS ) cls.create_and_enroll_student( courses=[cls.test_course_1, cls.test_course_2], username='student_masters_not_on_team', mode=CourseMode.MASTERS ) with skip_signal( post_save, receiver=course_team_post_save_callback, sender=CourseTeam, dispatch_uid='teams.signals.course_team_post_save_callback' ): cls.solar_team = CourseTeamFactory.create( name='Sólar team', course_id=cls.test_course_1.id, topic_id='topic_0' ) cls.wind_team = CourseTeamFactory.create( name='Wind Team', course_id=cls.test_course_1.id, topic_id='topic_1' ) cls.nuclear_team = CourseTeamFactory.create( name='Nuclear Team', course_id=cls.test_course_1.id, topic_id='topic_2' ) cls.another_team = CourseTeamFactory.create( name='Another Team', course_id=cls.test_course_2.id, topic_id='topic_5' ) cls.public_profile_team = CourseTeamFactory.create( name='Public Profile Team', course_id=cls.test_course_2.id, topic_id='topic_6' ) cls.search_team = CourseTeamFactory.create( name='Search', description='queryable text', country='GS', language='to', course_id=cls.test_course_2.id, topic_id='topic_7' ) cls.chinese_team = CourseTeamFactory.create( name='著文企臺個', description='共樣地面較,件展冷不護者這與民教過住意,國制銀產物助音是勢一友', country='CN', language='zh_HANS', course_id=cls.test_course_2.id, topic_id='topic_7' ) cls.masters_only_team = CourseTeamFactory.create( name='masters_course_1', description='masters student group', country='US', language='EN', course_id=cls.test_course_1.id, topic_id='topic_0', organization_protected=True ) cls.team_1_in_private_teamset_1 = CourseTeamFactory.create( name='team 1 in private teamset 1', description='team 1 in private teamset 1 desc', country='US', language='EN', course_id=cls.test_course_1.id, topic_id='private_topic_1_id', organization_protected=True ) cls.team_2_in_private_teamset_1 = CourseTeamFactory.create( name='team 2 in private teamset 1', description='team 2 in private teamset 1 desc', country='US', language='EN', course_id=cls.test_course_1.id, topic_id='private_topic_1_id', organization_protected=True ) cls.team_1_in_private_teamset_2 = CourseTeamFactory.create( name='team 1 in private teamset 2', description='team 1 in private teamset 2 desc', country='US', language='EN', course_id=cls.test_course_1.id, topic_id='private_topic_2_id', organization_protected=True ) cls.test_team_name_id_map = {team.name: team for team in ( cls.solar_team, cls.wind_team, cls.nuclear_team, cls.another_team, cls.public_profile_team, cls.search_team, cls.chinese_team, cls.masters_only_team, cls.team_1_in_private_teamset_1, cls.team_2_in_private_teamset_1, cls.team_1_in_private_teamset_2, )} for user, course in [('staff', cls.test_course_1), ('course_staff', cls.test_course_1)]: CourseEnrollment.enroll( cls.users[user], course.id, check_access=True ) # Django Rest Framework v3 requires us to pass a request to serializers # that have URL fields. Since we're invoking this code outside the context # of a request, we need to simulate that there's a request. cls.solar_team.add_user(cls.users['student_enrolled']) cls.nuclear_team.add_user(cls.users['student_enrolled_both_courses_other_team']) cls.another_team.add_user(cls.users['student_enrolled_both_courses_other_team']) cls.public_profile_team.add_user(cls.users['student_enrolled_public_profile']) cls.masters_only_team.add_user(cls.users['student_masters']) cls.team_1_in_private_teamset_1.add_user(cls.users['student_on_team_1_private_set_1']) cls.team_2_in_private_teamset_1.add_user(cls.users['student_on_team_2_private_set_1']) def build_membership_data_raw(self, username, team): """Assembles a membership creation payload based on the raw values provided.""" return {'username': username, 'team_id': team} def build_membership_data(self, username, team): """Assembles a membership creation payload based on the username and team model provided.""" return self.build_membership_data_raw(self.users[username].username, team.team_id) @classmethod def create_and_enroll_student(cls, courses=None, username=None, mode=None, external_key=None): """ Creates a new student and enrolls that student in the course. Adds the new user to the cls.users dictionary with the username as the key. Returns the username once the user has been created. """ if username is not None: user = UserFactory.create(password=cls.test_password, username=username) else: user = UserFactory.create(password=cls.test_password) courses = courses if courses is not None else [cls.test_course_1] for course in courses: CourseEnrollment.enroll(user, course.id, mode=mode, check_access=True) cls.users[user.username] = user if external_key is not None: ProgramEnrollmentFactory( user=user, external_user_key=external_key, program_uuid=UUID("88888888-4444-3333-1111-000000000000"), curriculum_uuid=UUID("77777777-4444-2222-1111-000000000000"), status='enrolled' ) return user.username def login(self, user): """Given a user string, logs the given user in. Used for testing with ddt, which does not have access to self in decorators. If user is 'student_inactive', then an inactive user will be both created and logged in. """ if user == 'student_inactive': student_inactive = UserFactory.create(password=self.test_password) self.client.login(username=student_inactive.username, password=self.test_password) student_inactive.is_active = False student_inactive.save() else: self.client.login(username=self.users[user].username, password=self.test_password) def make_call(self, url, expected_status=200, method='get', data=None, content_type=None, **kwargs): """Makes a call to the Team API at the given url with method and data. If a user is specified in kwargs, that user is first logged in. """ user = kwargs.pop('user', 'student_enrolled_not_on_team') if user: self.login(user) func = getattr(self.client, method) if content_type: response = func(url, data=data, content_type=content_type) else: response = func(url, data=data) assert expected_status == response.status_code, "Expected status {expected} but got {actual}: {content}"\ .format(expected=expected_status, actual=response.status_code, content=response.content.decode(response.charset)) if expected_status == 200: return json.loads(response.content.decode('utf-8')) else: return response def get_teams_list(self, expected_status=200, data=None, no_course_id=False, **kwargs): """Gets the list of teams as the given user with data as query params. Verifies expected_status.""" data = data if data else {} if 'course_id' not in data and not no_course_id: data.update({'course_id': str(self.test_course_1.id)}) return self.make_call(reverse('teams_list'), expected_status, 'get', data, **kwargs) def get_user_course_specific_teams_list(self): """Gets the list of user course specific teams.""" # Create and enroll user in both courses user = self.create_and_enroll_student( courses=[self.test_course_1, self.test_course_2], username='test_user_enrolled_both_courses' ) course_one_data = {'course_id': str(self.test_course_1.id), 'username': user} course_two_data = {'course_id': str(self.test_course_2.id), 'username': user} # Check that initially list of user teams in course one is empty team_list = self.get_teams_list(user=user, expected_status=200, data=course_one_data) assert team_list['count'] == 0 # Add user to a course one team self.solar_team.add_user(self.users[user]) # Check that list of user teams in course one is not empty now team_list = self.get_teams_list(user=user, expected_status=200, data=course_one_data) assert team_list['count'] == 1 # Check that list of user teams in course two is still empty team_list = self.get_teams_list(user=user, expected_status=200, data=course_two_data) assert team_list['count'] == 0 def build_team_data( self, name="Test team", course=None, description="Filler description", topic_id="topic_0", **kwargs ): """Creates the payload for creating a team. kwargs can be used to specify additional fields.""" data = kwargs course = course if course else self.test_course_1 data.update({ 'name': name, 'course_id': str(course.id), 'description': description, 'topic_id': topic_id, }) return data def post_create_team(self, expected_status=200, data=None, **kwargs): """Posts data to the team creation endpoint. Verifies expected_status.""" #return self.make_call(reverse('teams_list'), expected_status, 'post', data, topic_id='topic_0', **kwargs) return self.make_call(reverse('teams_list'), expected_status, 'post', data, **kwargs) def get_team_detail(self, team_id, expected_status=200, data=None, **kwargs): """Gets detailed team information for team_id. Verifies expected_status.""" return self.make_call(reverse('teams_detail', args=[team_id]), expected_status, 'get', data, **kwargs) def delete_team(self, team_id, expected_status, **kwargs): """Delete the given team. Verifies expected_status.""" return self.make_call(reverse('teams_detail', args=[team_id]), expected_status, 'delete', **kwargs) def patch_team_detail(self, team_id, expected_status, data=None, **kwargs): """Patches the team with team_id using data. Verifies expected_status.""" return self.make_call( reverse('teams_detail', args=[team_id]), expected_status, 'patch', json.dumps(data) if data else None, 'application/merge-patch+json', **kwargs ) def get_team_assignments(self, team_id, expected_status=200, **kwargs): """ Get the open response assessments assigned to a team """ return self.make_call( reverse('teams_assignments_list', args=[team_id]), expected_status, **kwargs ) def get_topics_list(self, expected_status=200, data=None, **kwargs): """Gets the list of topics, passing data as query params. Verifies expected_status.""" return self.make_call(reverse('topics_list'), expected_status, 'get', data, **kwargs) def get_topic_detail(self, topic_id, course_id, expected_status=200, data=None, **kwargs): """Gets a single topic, passing data as query params. Verifies expected_status.""" return self.make_call( reverse('topics_detail', kwargs={'topic_id': topic_id, 'course_id': str(course_id)}), expected_status, 'get', data, **kwargs ) def get_membership_list(self, expected_status=200, data=None, **kwargs): """Gets the membership list, passing data as query params. Verifies expected_status.""" return self.make_call(reverse('team_membership_list'), expected_status, 'get', data, **kwargs) def post_create_membership(self, expected_status=200, data=None, **kwargs): """Posts data to the membership creation endpoint. Verifies expected_status.""" return self.make_call(reverse('team_membership_list'), expected_status, 'post', data, **kwargs) def get_membership_detail(self, team_id, username, expected_status=200, data=None, **kwargs): """Gets an individual membership record, passing data as query params. Verifies expected_status.""" return self.make_call( reverse('team_membership_detail', args=[team_id, username]), expected_status, 'get', data, **kwargs ) def delete_membership(self, team_id, username, expected_status=200, **kwargs): """Deletes an individual membership record. Verifies expected_status.""" url = reverse('team_membership_detail', args=[team_id, username]) + '?admin=true' return self.make_call(url, expected_status, 'delete', **kwargs) def verify_expanded_public_user(self, user): """Verifies that fields exist on the returned user json indicating that it is expanded.""" for field in ['username', 'url', 'bio', 'country', 'profile_image', 'time_zone', 'language_proficiencies']: assert field in user def verify_expanded_private_user(self, user): """Verifies that fields exist on the returned user json indicating that it is expanded.""" for field in ['username', 'url', 'profile_image']: assert field in user for field in ['bio', 'country', 'time_zone', 'language_proficiencies']: assert field not in user def verify_expanded_team(self, team): """Verifies that fields exist on the returned team json indicating that it is expanded.""" for field in ['id', 'name', 'course_id', 'topic_id', 'date_created', 'description']: assert field in team def reset_search_index(self): """Clear out the search index and reindex the teams.""" CourseTeamIndexer.engine().destroy() for team in self.test_team_name_id_map.values(): CourseTeamIndexer.index(team) @ddt.ddt class TestListTeamsAPI(EventTestMixin, TeamAPITestCase): """Test cases for the team listing API endpoint.""" def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.teams.utils.tracker') @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 403), ('student_enrolled', 200, 3), ('staff', 200, 7), ('course_staff', 200, 7), ('community_ta', 200, 3), ('student_masters', 200, 1) ) @ddt.unpack def test_access(self, user, status, expected_teams_count=0): teams = self.get_teams_list(user=user, expected_status=status) if status == 200: assert expected_teams_count == teams['count'] def test_missing_course_id(self): self.get_teams_list(400, no_course_id=True) def verify_names(self, data, status, names=None, **kwargs): """Gets a team listing with data as query params, verifies status, and then verifies team names if specified.""" teams = self.get_teams_list(data=data, expected_status=status, **kwargs) if names is not None and 200 <= status < 300: results = teams['results'] assert sorted(names) == sorted([team['name'] for team in results]) def test_filter_invalid_course_id(self): self.verify_names({'course_id': 'no_such_course'}, 400) def test_filter_course_id(self): self.verify_names( {'course_id': str(self.test_course_2.id)}, 200, ['Another Team', 'Public Profile Team', 'Search', '著文企臺個'], user='staff' ) def test_filter_topic_id(self): self.verify_names({'course_id': str(self.test_course_1.id), 'topic_id': 'topic_0'}, 200, ['Sólar team']) def test_filter_username(self): self.verify_names({'course_id': str(self.test_course_1.id), 'username': 'student_enrolled'}, 200, ['Sólar team']) self.verify_names({'course_id': str(self.test_course_1.id), 'username': 'staff'}, 200, []) @ddt.data( (None, 200, ['Nuclear Team', 'Sólar team', 'Wind Team']), ('name', 200, ['Nuclear Team', 'Sólar team', 'Wind Team']), # Note that "Nuclear Team" and "Solar team" have the same open_slots. # "Solar team" comes first due to secondary sort by last_activity_at. ('open_slots', 200, ['Wind Team', 'Sólar team', 'Nuclear Team']), # Note that "Wind Team" and "Nuclear Team" have the same last_activity_at. # "Wind Team" comes first due to secondary sort by open_slots. ('last_activity_at', 200, ['Sólar team', 'Wind Team', 'Nuclear Team']), ) @ddt.unpack def test_order_by(self, field, status, names): # Make "Solar team" the most recently active team. # The CourseTeamFactory sets the last_activity_at to a fixed time (in the past), so all of the # other teams have the same last_activity_at. with skip_signal( post_save, receiver=course_team_post_save_callback, sender=CourseTeam, dispatch_uid='teams.signals.course_team_post_save_callback' ): solar_team = self.test_team_name_id_map['Sólar team'] solar_team.last_activity_at = datetime.utcnow().replace(tzinfo=pytz.utc) solar_team.save() data = {'order_by': field} if field else {} self.verify_names(data, status, names) def test_order_by_with_text_search(self): data = {'order_by': 'name', 'text_search': 'search'} self.verify_names(data, 400, []) self.assert_no_events_were_emitted() @ddt.data((404, {'course_id': 'no/such/course'}), (400, {'topic_id': 'no_such_topic'})) @ddt.unpack def test_no_results(self, status, data): self.get_teams_list(status, data) def test_page_size(self): result = self.get_teams_list(200, {'page_size': 2}) assert 2 == result['num_pages'] def test_non_member_trying_to_get_private_topic(self): """ Verifies that when a student that is enrolled in a course, but is NOT a member of a private team set, asks for information about that team set, an empty list is returned. """ result = self.get_teams_list(data={'topic_id': 'private_topic_1_id'}) assert [] == result['results'] def test_member_trying_to_get_private_topic(self): """ Verifies that when a student that is enrolled in a course, and IS a member of a private team set, asks for information about that team set, information about the teamset is returned. """ result = self.get_teams_list(data={'topic_id': 'private_topic_1_id'}, user='student_on_team_1_private_set_1') assert 1 == len(result['results']) assert 'private_topic_1_id' == result['results'][0]['topic_id'] assert [] != result['results'] def test_course_staff_getting_information_on_private_topic(self): """ Verifies that when an admin browses to a private team set, information about the teams in the teamset is returned even if the admin is not in any teams. """ result = self.get_teams_list(data={'topic_id': 'private_topic_1_id'}, user='course_staff') assert 2 == len(result['results']) @ddt.unpack @ddt.data( ('student_masters_not_on_team', 1), ('student_masters', 1), ('student_enrolled', 0), ('staff', 1), ) def test_text_search_organization_protected(self, user, expected_results): """ When doing a text search as different users, will the masters_only team show up? Only staff, or people who are within the organization_protected bubble should be able to see the masters team """ self.reset_search_index() result = self.get_teams_list( data={'text_search': 'master'}, user=user, ) assert result['count'] == expected_results @ddt.unpack @ddt.data( ('student_on_team_1_private_set_1', True, False, False), ('student_on_team_2_private_set_1', False, True, False), ('student_enrolled', False, False, False), ('student_masters', False, False, False), ('staff', True, True, True), ) def test_text_search_private_teamset(self, user, can_see_private_1_1, can_see_private_1_2, can_see_private_2_1): """ When doing a text search as different users, will private_managed teams show up? Only staff should be able to see all private_managed teams. Students enrolled in a private_managed teams should be able to see their team, and no others. """ self.reset_search_index() result = self.get_teams_list( data={'text_search': 'private'}, user=user, ) teams = {team['name'] for team in result['results']} expected_teams = set() if can_see_private_1_1: expected_teams.add(self.team_1_in_private_teamset_1.name) if can_see_private_1_2: expected_teams.add(self.team_2_in_private_teamset_1.name) if can_see_private_2_1: expected_teams.add(self.team_1_in_private_teamset_2.name) assert expected_teams == teams def test_page(self): result = self.get_teams_list(200, {'page_size': 1, 'page': 3}) assert 3 == result['num_pages'] assert result['next'] is None assert result['previous'] is not None def test_expand_private_user(self): # Use the default user which is already private because to year_of_birth is set result = self.get_teams_list(200, {'expand': 'user', 'topic_id': 'topic_0'}) self.verify_expanded_private_user(result['results'][0]['membership'][0]['user']) def test_expand_public_user(self): result = self.get_teams_list( 200, { 'expand': 'user', 'topic_id': 'topic_6', 'course_id': str(self.test_course_2.id) }, user='student_enrolled_public_profile' ) self.verify_expanded_public_user(result['results'][0]['membership'][0]['user']) @ddt.data( ('search', ['Search']), ('queryable', ['Search']), ('Tonga', ['Search']), ('Island', ['Search']), ('not-a-query', []), ('team', ['Another Team', 'Public Profile Team']), ('著文企臺個', ['著文企臺個']), ) @ddt.unpack def test_text_search(self, text_search, expected_team_names): self.reset_search_index() self.verify_names( {'course_id': str(self.test_course_2.id), 'text_search': text_search}, 200, expected_team_names, user='student_enrolled_public_profile' ) self.assert_event_emitted( 'edx.team.searched', search_text=text_search, topic_id=None, number_of_results=len(expected_team_names) ) # Verify that the searches still work for a user from a different locale with translation.override('ar'): self.reset_search_index() self.verify_names( {'course_id': str(self.test_course_2.id), 'text_search': text_search}, 200, expected_team_names, user='student_enrolled_public_profile' ) @ddt.data( ('masters', ['masters_course_1']), ('group', ['masters_course_1']), ('Sólar', []), ('Wind', []), ) @ddt.unpack def test_text_search_masters(self, text_search, expected_team_names): # Verify that the search is working with Masters learner self.reset_search_index() self.verify_names( {'course_id': str(self.test_course_1.id), 'text_search': text_search}, 200, expected_team_names, user='student_masters' ) def test_delete_removed_from_search(self): team = CourseTeamFactory.create( name='zoinks', course_id=self.test_course_1.id, topic_id='topic_0' ) self.verify_names( {'course_id': str(self.test_course_1.id), 'text_search': 'zoinks'}, 200, [team.name], user='staff' ) team.delete() self.verify_names( {'course_id': str(self.test_course_1.id), 'text_search': 'zoinks'}, 200, [], user='staff' ) def test_duplicates_and_nontopic_private_teamsets(self): """ Test for a bug where non-admin users would have their private memberships returned from this endpoint despite the topic, and duplicate entries for teams in the topic that was being queried (EDUCATOR-5042) """ # create a team in a private teamset and add a user unprotected_team_in_private_teamset = CourseTeamFactory.create( name='unprotected_team_in_private_teamset', description='unprotected_team_in_private_teamset', course_id=self.test_course_1.id, topic_id='private_topic_1_id', ) unprotected_team_in_private_teamset.add_user(self.users['student_enrolled']) # make some more users and put them in the solar team. another_student_username = 'another_student' yet_another_student_username = 'yet_another_student' self.create_and_enroll_student(username=another_student_username) self.create_and_enroll_student(username=yet_another_student_username) self.solar_team.add_user(self.users[another_student_username]) self.solar_team.add_user(self.users[yet_another_student_username]) teams = self.get_teams_list(data={'topic_id': self.solar_team.topic_id}, user='student_enrolled') team_names = [team['name'] for team in teams['results']] team_names.sort() assert team_names == [self.solar_team.name] teams = self.get_teams_list(data={'topic_id': self.solar_team.topic_id}, user='staff') team_names = [team['name'] for team in teams['results']] team_names.sort() assert team_names == [self.solar_team.name, self.masters_only_team.name] @ddt.ddt class TestCreateTeamAPI(EventTestMixin, TeamAPITestCase): """Test cases for the team creation endpoint.""" def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.teams.utils.tracker') @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 403), ('student_enrolled_not_on_team', 200), ('staff', 200), ('course_staff', 200), ('community_ta', 200), ) @ddt.unpack def test_access(self, user, status): team = self.post_create_team(status, self.build_team_data(name="New Team"), user=user) if status == 200: self.verify_expected_team_id(team, 'new-team') teams = self.get_teams_list(user=user) assert 'New Team' in [team['name'] for team in teams['results']] def _expected_team_id(self, team, expected_prefix): """ Return the team id that we'd expect given this team data and this prefix. """ return expected_prefix + '-' + team['discussion_topic_id'] def verify_expected_team_id(self, team, expected_prefix): """ Verifies that the team id starts with the specified prefix and ends with the discussion_topic_id """ assert 'id' in team assert 'discussion_topic_id' in team assert team['id'] == self._expected_team_id(team, expected_prefix) def test_naming(self): new_teams = [ self.post_create_team(data=self.build_team_data(name=name), user=self.create_and_enroll_student()) for name in ["The Best Team", "The Best Team", "A really long team name"] ] # Check that teams with the same name have unique IDs. self.verify_expected_team_id(new_teams[0], 'the-best-team') self.verify_expected_team_id(new_teams[1], 'the-best-team') assert new_teams[0]['id'] != new_teams[1]['id'] # Verify expected truncation behavior with names > 20 characters. self.verify_expected_team_id(new_teams[2], 'a-really-long-team-n') @ddt.data((400, { 'name': 'Bad Course ID', 'course_id': 'no_such_course', 'description': "Filler Description" }), (404, { 'name': "Non-existent course ID", 'course_id': 'no/such/course', 'description': "Filler Description" })) @ddt.unpack def test_bad_course_data(self, status, data): self.post_create_team(status, data) def test_bad_topic_id(self): self.post_create_team( 404, data=self.build_team_data(topic_id='asdfasdfasdfa'), user='staff' ) def test_missing_topic_id(self): data = self.build_team_data() data.pop('topic_id') self.post_create_team(400, data=data, user='staff') def test_student_in_teamset(self): response = self.post_create_team( 400, data=self.build_team_data( name="Doomed team", course=self.test_course_1, description="Overly ambitious student" ), user='student_enrolled' ) assert 'You are already in a team in this teamset.' ==\ json.loads(response.content.decode('utf-8'))['user_message'] @patch('lms.djangoapps.teams.views.can_user_create_team_in_topic', return_value=False) @patch('lms.djangoapps.teams.views.has_specific_teamset_access', return_value=True) def test_student_create_team_instructor_managed_topic(self, *args): # pylint: disable=unused-argument response = self.post_create_team( 403, data=self.build_team_data( name="student create team in instructor managed topic", course=self.test_course_1, description="student cannot create team in instructor-managed topic", topic_id='private_topic_1_id' ), user='student_enrolled_not_on_team' ) assert "You can't create a team in an instructor managed topic." ==\ json.loads(response.content.decode('utf-8'))['user_message'] @ddt.data('staff', 'course_staff', 'community_ta') def test_privileged_create_multiple_teams(self, user): """ Privileged users can create multiple teams, even if they are already in one. """ # First add the privileged user to a team. self.post_create_membership( 200, self.build_membership_data(user, self.solar_team), user=user ) self.post_create_team( data=self.build_team_data( name="Another team", course=self.test_course_1, description="Privileged users are the best", topic_id=self.solar_team.topic_id ), user=user ) @ddt.data({'description': ''}, {'name': 'x' * 1000}, {'name': ''}) def test_bad_fields(self, kwargs): self.post_create_team(400, self.build_team_data(**kwargs)) def test_missing_name(self): self.post_create_team(400, { 'course_id': str(self.test_course_1.id), 'description': "foobar" }) def test_full_student_creator(self): creator = self.create_and_enroll_student() team = self.post_create_team(data=self.build_team_data( name="Fully specified team", course=self.test_course_1, description="Another fantastic team", topic_id='topic_1', country='CA', language='fr' ), user=creator) # Verify the id (it ends with a unique hash, which is the same as the discussion_id). self.verify_expected_team_id(team, 'fully-specified-team') del team['id'] self.assert_event_emitted( 'edx.team.created', team_id=self._expected_team_id(team, 'fully-specified-team'), ) self.assert_event_emitted( 'edx.team.learner_added', team_id=self._expected_team_id(team, 'fully-specified-team'), user_id=self.users[creator].id, add_method='added_on_create' ) # Remove date_created and discussion_topic_id because they change between test runs del team['date_created'] del team['discussion_topic_id'] # Since membership is its own list, we want to examine this separately. team_membership = team['membership'] del team['membership'] # verify that it's been set to a time today. assert parser.parse(team['last_activity_at']).date() == datetime.utcnow().replace(tzinfo=pytz.utc).date() del team['last_activity_at'] # Verify that the creating user gets added to the team. assert len(team_membership) == 1 member = team_membership[0]['user'] assert member['username'] == creator assert team == {'name': 'Fully specified team', 'language': 'fr', 'country': 'CA', 'topic_id': 'topic_1', 'course_id': str(self.test_course_1.id), 'description': 'Another fantastic team', 'organization_protected': False} @ddt.data('staff', 'course_staff', 'community_ta') def test_membership_staff_creator(self, user): # Verify that staff do not automatically get added to a team # when they create one. team = self.post_create_team(data=self.build_team_data( name="New team", course=self.test_course_1, description="Another fantastic team", ), user=user) assert team['membership'] == [] @ddt.unpack @ddt.data( ('student_enrolled', 404, None), ('student_unenrolled', 403, None), ('student_enrolled_not_on_team', 404, None), ('student_masters', 404, None), ('student_on_team_1_private_set_1', 403, "You can't create a team in an instructor managed topic."), ('student_on_team_2_private_set_1', 403, "You can't create a team in an instructor managed topic."), ('staff', 200, None) ) def test_private_managed_access(self, user, expected_response, msg): """ As different users, check if we can create a team in a private teamset. Only staff should be able to create teams in managed teamsets, but they're also the only ones who should know that private_managed teamsets exist. If the team hasn't been created yet, no one can be in it, so no non-staff should get any info at all from this endpoint. """ response = self.post_create_team( expected_response, data=self.build_team_data( name="test_private_managed_access", course=self.test_course_1, description="test_private_managed_access", topic_id="private_topic_1_id" ), user=user ) if msg: assert msg == response.json()['user_message'] @ddt.ddt class TestDetailTeamAPI(TeamAPITestCase): """Test cases for the team detail endpoint.""" @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 403), ('student_enrolled', 200), ('staff', 200), ('course_staff', 200), ('community_ta', 200), ) @ddt.unpack def test_access(self, user, status): team = self.get_team_detail(self.solar_team.team_id, status, user=user) if status == 200: assert team['description'] == self.solar_team.description assert team['discussion_topic_id'] == self.solar_team.discussion_topic_id assert parser.parse(team['last_activity_at']) == LAST_ACTIVITY_AT def test_does_not_exist(self): self.get_team_detail('no_such_team', 404) def test_expand_private_user(self): # Use the default user which is already private because to year_of_birth is set result = self.get_team_detail(self.solar_team.team_id, 200, {'expand': 'user'}) self.verify_expanded_private_user(result['membership'][0]['user']) def test_expand_public_user(self): result = self.get_team_detail( self.public_profile_team.team_id, 200, {'expand': 'user'}, user='student_enrolled_public_profile' ) self.verify_expanded_public_user(result['membership'][0]['user']) @ddt.unpack @ddt.data( ('student_unenrolled', 403), ('student_enrolled', 404), ('student_masters_not_on_team', 200), ('student_masters', 200), ('staff', 200) ) def test_organization_protected(self, requesting_user, expected_response): """ As different users, check if we can request the masters_only team detail. Only staff and users within the organization_protection bubble should be able to get info about an organization_protected team, or be able to tell that it exists. """ team = self.get_team_detail( self.masters_only_team.team_id, expected_response, user=requesting_user ) if expected_response == 200: assert team['name'] == self.masters_only_team.name @ddt.unpack @ddt.data( ('student_unenrolled', 403), ('student_enrolled', 404), ('student_masters', 404), ('student_on_team_1_private_set_1', 200), ('student_on_team_2_private_set_1', 404), ('staff', 200) ) def test_teamset_types(self, requesting_user, expected_response): """ As different users, check if we can request the masters_only team detail. Only staff or users enrolled in the team should be able to get info about a private_managed team, or even be able to tell that it exists. """ team = self.get_team_detail( self.team_1_in_private_teamset_1.team_id, expected_response, user=requesting_user ) if expected_response == 200: assert team['name'] == self.team_1_in_private_teamset_1.name @ddt.ddt class TestDeleteTeamAPI(EventTestMixin, TeamAPITestCase): """Test cases for the team delete endpoint.""" def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.teams.utils.tracker') @ddt.data( ('staff', 204), ('course_staff', 204), ('community_ta', 204), ('admin', 204) ) @ddt.unpack def test_access(self, user, status): team_list = self.get_teams_list(user='course_staff', expected_status=200) previous_count = team_list['count'] assert self.solar_team.team_id in [result['id'] for result in team_list.get('results')] self.delete_team(self.solar_team.team_id, status, user=user) team_list = self.get_teams_list(user='course_staff', expected_status=200) assert team_list['count'] == (previous_count - 1) assert self.solar_team.team_id not in [result['id'] for result in team_list.get('results')] self.assert_event_emitted( 'edx.team.deleted', team_id=self.solar_team.team_id, ) self.assert_event_emitted( 'edx.team.learner_removed', team_id=self.solar_team.team_id, remove_method='team_deleted', user_id=self.users['student_enrolled'].id ) @ddt.data( ('student_unenrolled', 403), ('student_enrolled', 403), ) @ddt.unpack def test_access_forbidden(self, user, status): team_list = self.get_teams_list(user='course_staff', expected_status=200) previous_count = team_list['count'] assert self.solar_team.team_id in [result['id'] for result in team_list.get('results')] self.delete_team(self.solar_team.team_id, status, user=user) team_list = self.get_teams_list(user='course_staff', expected_status=200) assert team_list['count'] == previous_count assert self.solar_team.team_id in [result['id'] for result in team_list.get('results')] @ddt.data( (None, 401), ('student_inactive', 401), ) @ddt.unpack def test_access_unauthorized(self, user, status): self.delete_team(self.solar_team.team_id, status, user=user) def test_does_not_exist(self): self.delete_team('nonexistent', 404) def test_memberships_deleted(self): assert CourseTeamMembership.objects.filter(team=self.solar_team).count() == 1 self.delete_team(self.solar_team.team_id, 204, user='staff') self.assert_event_emitted( 'edx.team.deleted', team_id=self.solar_team.team_id, ) self.assert_event_emitted( 'edx.team.learner_removed', team_id=self.solar_team.team_id, remove_method='team_deleted', user_id=self.users['student_enrolled'].id ) assert CourseTeamMembership.objects.filter(team=self.solar_team).count() == 0 @ddt.unpack @ddt.data( ('student_unenrolled', 403), ('student_enrolled', 404), ('student_masters_not_on_team', 403), ('student_masters', 403), ('staff', 204) ) def test_organization_protection_status(self, requesting_user, expected_status): """ As different users, try to delete the masters-only team. Only staff should be able to delete this team, and people outside the bubble shouldn't be able to tell that it even exists. """ self.delete_team( self.masters_only_team.team_id, expected_status, user=requesting_user ) @ddt.unpack @ddt.data( ('student_unenrolled', 403), ('student_enrolled', 404), ('student_on_team_1_private_set_1', 403), ('student_on_team_2_private_set_1', 404), ('staff', 204) ) def test_teamset_type(self, requesting_user, expected_status): """ As different users, try to delete a private_managed team Only staff should be able to delete a private_managed team, and only they and users enrolled in that team should even be able to tell that it exists. """ self.delete_team( self.team_1_in_private_teamset_1.team_id, expected_status, user=requesting_user ) @ddt.ddt class TestUpdateTeamAPI(EventTestMixin, TeamAPITestCase): """Test cases for the team update endpoint.""" def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.teams.utils.tracker') @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 403), ('student_enrolled', 403), ('staff', 200), ('course_staff', 200), ('community_ta', 200), ) @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: assert team['name'] == 'foo' self.assert_event_emitted( 'edx.team.changed', team_id=self.solar_team.team_id, truncated=[], field='name', old=prev_name, new='foo' ) @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 404), ('student_enrolled', 404), ('staff', 404), ('course_staff', 404), ('community_ta', 404), ) @ddt.unpack def test_access_bad_id(self, user, status): self.patch_team_detail("no_such_team", status, {'name': 'foo'}, user=user) @ddt.data( ('id', 'foobar'), ('description', ''), ('country', 'no_such_country'), ('language', 'no_such_language') ) @ddt.unpack def test_bad_requests(self, key, value): self.patch_team_detail(self.solar_team.team_id, 400, {key: value}, user='staff') @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, truncated=[], field=key, old=prev_value, new=value ) def test_does_not_exist(self): self.patch_team_detail('no_such_team', 404, user='staff') @ddt.unpack @ddt.data( ('student_unenrolled', 403), ('student_enrolled', 404), ('student_masters_not_on_team', 403), ('student_masters', 403), ('staff', 200) ) def test_organization_protection_status(self, requesting_user, expected_status): """ As different users, try to modify the masters-only team. Only staff should be able to modify this team, and people outside the bubble shouldn't be able to tell that it even exists. """ team = self.patch_team_detail( self.masters_only_team.team_id, expected_status, {'name': 'foo'}, user=requesting_user ) if expected_status == 200: assert team['name'] == 'foo' @ddt.unpack @ddt.data( ('student_unenrolled', 403), ('student_enrolled', 404), ('student_on_team_1_private_set_1', 403), ('student_on_team_2_private_set_1', 404), ('staff', 200) ) def test_teamset_type(self, requesting_user, expected_status): """ As different users, try to modify a private_managed team Only staff should be able to modify a private_managed team, and only they and users enrolled in that team should even be able to tell that it exists. """ team = self.patch_team_detail( self.team_1_in_private_teamset_1.team_id, expected_status, {'name': 'foo'}, user=requesting_user ) if expected_status == 200: assert team['name'] == 'foo' @patch.dict(settings.FEATURES, {'ENABLE_ORA_TEAM_SUBMISSIONS': True}) @ddt.ddt class TestTeamAssignmentsView(TeamAPITestCase): """ Tests for the TeamAssignmentsView """ @classmethod def setUpClass(cls): """ Create an openassessment block for testing """ super().setUpClass() course = cls.test_course_1 teamset_id = cls.solar_team.topic_id other_teamset_id = cls.wind_team.topic_id section = ItemFactory.create( parent=course, category='chapter', display_name='Test Section' ) subsection = ItemFactory.create( parent=section, category="sequential" ) unit_1 = ItemFactory.create( parent=subsection, category="vertical" ) open_assessment = ItemFactory.create( parent=unit_1, category="openassessment", teams_enabled=True, selected_teamset_id=teamset_id ) unit_2 = ItemFactory.create( parent=subsection, category="vertical" ) off_team_open_assessment = ItemFactory.create( # pylint: disable=unused-variable parent=unit_2, category="openassessment", teams_enabled=True, selected_teamset_id=other_teamset_id ) cls.team_assignments = [open_assessment] @ddt.unpack @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 403), ('student_on_team_2_private_set_1', 404), ('student_enrolled', 200), ('staff', 200), ('course_staff', 200), ('community_ta', 200), ) def test_get_assignments(self, user, expected_status): # Given a course with team-enabled open responses team_id = self.solar_team.team_id # When I get the assignments for a team assignments = self.get_team_assignments(team_id, expected_status, user=user) if expected_status == 200: # I successful, I get back the assignments for a team assert len(assignments) == len(self.team_assignments) # ... with the right data structure for assignment in assignments: assert 'display_name' in assignment.keys() assert 'location' in assignment.keys() def test_get_assignments_bad_team(self): # Given a bad team is supplied user = 'student_enrolled' team_id = 'bogus-team' # When I run the query, I get back a 404 error expected_status = 404 self.get_team_assignments(team_id, expected_status, user=user) @patch.dict(settings.FEATURES, {'ENABLE_ORA_TEAM_SUBMISSIONS': False}) def test_get_assignments_feature_not_enabled(self): # Given the team submissions feature is not enabled user = 'student_enrolled' team_id = self.solar_team.team_id # When I try to get assignments # Then I get back a 503 error expected_status = 503 self.get_team_assignments(team_id, expected_status, user=user) @ddt.ddt class TestListTopicsAPI(TeamAPITestCase): """Test cases for the topic listing endpoint.""" @ddt.data( (None, 401, None), ('student_inactive', 401, None), ('student_unenrolled', 403, None), ('student_enrolled', 200, 4), ('staff', 200, 7), ('course_staff', 200, 7), ('community_ta', 200, 4), ) @ddt.unpack def test_access(self, user, status, expected_topics_count): topics = self.get_topics_list(status, {'course_id': str(self.test_course_1.id)}, user=user) if status == 200: assert topics['count'] == expected_topics_count @ddt.data('A+BOGUS+COURSE', 'A/BOGUS/COURSE') def test_invalid_course_key(self, course_id): self.get_topics_list(404, {'course_id': course_id}) def test_without_course_id(self): self.get_topics_list(400) @ddt.data( (None, 200, ['Coal Power', 'Nuclear Power', 'Sólar power', 'Wind Power'], 'name'), ('name', 200, ['Coal Power', 'Nuclear Power', 'Sólar power', 'Wind Power'], 'name'), # Note that "Nuclear Power" will have 2 teams. "Coal Power" "Wind Power" and "Solar Power" # all have 1 team. The secondary sort is alphabetical by name. ('team_count', 200, ['Nuclear Power', 'Coal Power', 'Sólar power', 'Wind Power'], 'team_count'), ('no_such_field', 400, [], None), ) @ddt.unpack def test_order_by(self, field, status, names, expected_ordering): with skip_signal( post_save, receiver=course_team_post_save_callback, sender=CourseTeam, dispatch_uid='teams.signals.course_team_post_save_callback' ): # Add a team to "Nuclear Power", so it has two teams CourseTeamFactory.create( name='Nuclear Team 1', course_id=self.test_course_1.id, topic_id='topic_2' ) # Add a team to "Coal Power", so it has one team, same as "Wind" and "Solar" CourseTeamFactory.create( name='Coal Team 1', course_id=self.test_course_1.id, topic_id='topic_3' ) data = {'course_id': str(self.test_course_1.id)} if field: data['order_by'] = field topics = self.get_topics_list(status, data, user='student_enrolled') if status == 200: assert names == [topic['name'] for topic in topics['results']] assert topics['sort_order'] == expected_ordering def test_order_by_team_count_secondary(self): """ Ensure that the secondary sort (alphabetical) when primary sort is team_count works across pagination boundaries. """ # All teams have one teamset, except for Coal Power, topic_3 with skip_signal( post_save, receiver=course_team_post_save_callback, sender=CourseTeam, dispatch_uid='teams.signals.course_team_post_save_callback' ): # Add two wind teams, a solar team and a coal team, to bring the totals to # Wind: 3 Solar: 2 Coal: 1, Nuclear: 1 CourseTeamFactory.create( name='Wind Team 1', course_id=self.test_course_1.id, topic_id='topic_1' ) CourseTeamFactory.create( name='Wind Team 2', course_id=self.test_course_1.id, topic_id='topic_1' ) CourseTeamFactory.create( name='Solar Team 1', course_id=self.test_course_1.id, topic_id='topic_0' ) CourseTeamFactory.create( name='Coal Team 1', course_id=self.test_course_1.id, topic_id='topic_3' ) # Wind power has the most teams, followed by Solar topics = self.get_topics_list( data={ 'course_id': str(self.test_course_1.id), 'page_size': 2, 'page': 1, 'order_by': 'team_count' }, user='student_enrolled' ) assert ['Wind Power', 'Sólar power'] == [topic['name'] for topic in topics['results']] # Coal and Nuclear are tied, so they are alphabetically sorted. topics = self.get_topics_list( data={ 'course_id': str(self.test_course_1.id), 'page_size': 2, 'page': 2, 'order_by': 'team_count' }, user='student_enrolled' ) assert ['Coal Power', 'Nuclear Power'] == [topic['name'] for topic in topics['results']] def test_pagination(self): response = self.get_topics_list( data={ 'course_id': str(self.test_course_1.id), 'page_size': 2, }, user='student_enrolled' ) assert 2 == len(response['results']) assert 'next' in response assert 'previous' in response assert response['previous'] is None assert response['next'] is not None def test_default_ordering(self): response = self.get_topics_list(data={'course_id': str(self.test_course_1.id)}) assert response['sort_order'] == 'name' def test_team_count(self): """Test that team_count is included for each topic""" response = self.get_topics_list( data={'course_id': str(self.test_course_1.id)}, user='student_enrolled' ) for topic in response['results']: assert 'team_count' in topic if topic['id'] in ('topic_0', 'topic_1', 'topic_2'): assert topic['team_count'] == 1 else: assert topic['team_count'] == 0 @ddt.unpack @ddt.data( ('student_enrolled', 0), ('student_on_team_1_private_set_1', 1), ('student_on_team_2_private_set_1', 1), ('student_masters', 0), ('staff', 3) ) def test_teamset_type(self, requesting_user, expected_private_teamsets): """ As different users, request course_1's list of topics, and see what private_managed teamsets are returned Staff should be able to see all teamsets, and anyone enrolled in a private teamset should see that and only that teamset """ topics = self.get_topics_list( data={'course_id': str(self.test_course_1.id)}, user=requesting_user ) private_teamsets_returned = [ topic['name'] for topic in topics['results'] if topic['type'] == 'private_managed' ] assert len(private_teamsets_returned) == expected_private_teamsets @ddt.unpack @ddt.data( ('student_on_team_1_private_set_1', 1), ('student_on_team_2_private_set_1', 1), ('staff', 2) ) def test_private_teamset_team_count(self, requesting_user, expected_team_count): """ Students should only see teams they are members of in private team-sets """ topics = self.get_topics_list( data={'course_id': str(self.test_course_1.id)}, user=requesting_user ) private_teamset_1 = [topic for topic in topics['results'] if topic['name'] == 'private_topic_1_name'][0] assert private_teamset_1['team_count'] == expected_team_count @ddt.ddt class TestDetailTopicAPI(TeamAPITestCase): """Test cases for the topic detail endpoint.""" @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 403), ('student_enrolled', 200), ('staff', 200), ('course_staff', 200), ('community_ta', 200), ) @ddt.unpack def test_access(self, user, status): topic = self.get_topic_detail('topic_0', self.test_course_1.id, status, user=user) if status == 200: for field in ('id', 'name', 'description'): assert field in topic @ddt.data('A+BOGUS+COURSE', 'A/BOGUS/COURSE') def test_invalid_course_id(self, course_id): self.get_topic_detail('topic_0', course_id, 404) def test_invalid_topic_id(self): self.get_topic_detail('no_such_topic', self.test_course_1.id, 404) def test_topic_detail_with_caps_and_dot_in_id(self): self.get_topic_detail('Topic_6.5', self.test_course_2.id, user='student_enrolled_public_profile') def test_team_count(self): """Test that team_count is included with a topic""" topic = self.get_topic_detail(topic_id='topic_0', course_id=self.test_course_1.id) assert topic['team_count'] == 1 topic = self.get_topic_detail(topic_id='topic_1', course_id=self.test_course_1.id) assert topic['team_count'] == 1 topic = self.get_topic_detail(topic_id='topic_2', course_id=self.test_course_1.id) assert topic['team_count'] == 1 topic = self.get_topic_detail(topic_id='topic_3', course_id=self.test_course_1.id) assert topic['team_count'] == 0 @ddt.unpack @ddt.data( ('student_enrolled', 404, None), ('student_on_team_1_private_set_1', 200, 1), ('student_on_team_2_private_set_1', 200, 1), ('student_masters', 404, None), ('staff', 200, 2) ) def test_teamset_type(self, requesting_user, expected_status, expected_team_count): """ As different users, request info about a private_managed team. Staff should be able to see all teamsets, and someone enrolled in a private_managed teamset should be able to see that and only that teamset. As shown in `test_invalid_topic_id`, nonexistant topics 404, and if someone doesn't have access to a private_managed teamset, as far as they know the teamset does not exist. """ topic = self.get_topic_detail( topic_id='private_topic_1_id', course_id=self.test_course_1.id, expected_status=expected_status, user=requesting_user ) if expected_status == 200: assert topic['name'] == 'private_topic_1_name' assert topic['team_count'] == expected_team_count @ddt.ddt class TestListMembershipAPI(TeamAPITestCase): """Test cases for the membership list endpoint.""" @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 404), ('student_enrolled', 200), ('student_enrolled_both_courses_other_team', 200), ('staff', 200), ('course_staff', 200), ('community_ta', 200), ) @ddt.unpack def test_access(self, user, status): membership = self.get_membership_list(status, {'team_id': self.solar_team.team_id}, user=user) if status == 200: assert membership['count'] == 1 assert membership['results'][0]['user']['username'] == self.users['student_enrolled'].username @ddt.data( (None, 401, False), ('student_inactive', 401, False), ('student_unenrolled', 200, False), ('student_enrolled', 200, True), ('student_enrolled_both_courses_other_team', 200, True), ('staff', 200, True), ('course_staff', 200, True), ('community_ta', 200, True), ) @ddt.unpack def test_access_by_username(self, user, status, has_content): membership = self.get_membership_list(status, {'username': self.users['student_enrolled'].username}, user=user) if status == 200: if has_content: assert membership['count'] == 1 assert membership['results'][0]['team']['team_id'] == self.solar_team.team_id else: assert membership['count'] == 0 @ddt.data( ('student_masters', True), ('student_masters_not_on_team', True), ('student_unenrolled', False), ('student_enrolled', True), ('student_enrolled_both_courses_other_team', True), ('staff', True), ) @ddt.unpack def test_access_by_username_organization_protected(self, user, can_see_bubble_team): """ As different users, request team membership info for student_masters Only staff, and users who are within the bubble should be able to see a bubble user's team memberships. Non-bubble users shouldn't be able to tell that student_masters exists. (Nonexistant users still return 200, just with no data.) TODO: Only the oragnization_protected users (student_masters, student_masters_not_on_team) and staff should be able to see student_masters """ membership = self.get_membership_list(200, {'username': 'student_masters'}, user=user) if can_see_bubble_team: assert membership['count'] == 1 assert membership['results'][0]['team']['team_id'] == self.masters_only_team.team_id else: assert membership['count'] == 0 @ddt.unpack @ddt.data( ('student_on_team_1_private_set_1', True, True), ('student_unenrolled', False, False), ('student_enrolled', True, True), ('student_on_team_2_private_set_1', True, True), ('student_masters', True, True), ('staff', True, True) ) def test_access_by_username_private_teamset(self, user, can_see_any_teams, can_see_private_team): """ Add student_on_team_1_private_set_1 to masters_only_team. Then, as different users, request team membership info for student_on_team_1_private_set_1. Anyone in the organization_protected bubble should be able to see the masters_only membership, but only staff and users in team_1_private_set_1 ahould be able to see that membership. TODO: student_enrolled shouldn't see any teams as he is outside the bubble. student_masters and sot2ps1 should only see masters_only team. """ self.masters_only_team.add_user(self.users['student_on_team_1_private_set_1']) memberships = self.get_membership_list(200, {'username': 'student_on_team_1_private_set_1'}, user=user) team_ids = [membership['team']['team_id'] for membership in memberships['results']] if can_see_private_team: assert len(team_ids) == 2 assert self.team_1_in_private_teamset_1.team_id in team_ids assert self.masters_only_team.team_id in team_ids elif can_see_any_teams: assert len(team_ids) == 1 assert self.masters_only_team.team_id in team_ids else: assert len(team_ids) == 0 @ddt.unpack @ddt.data( ('student_on_team_1_private_set_1', 200), ('student_unenrolled', 404), ('student_enrolled', 403), ('student_on_team_2_private_set_1', 403), ('student_masters', 403), ('staff', 200) ) def test_access_by_team_private_teamset(self, user, expected_response): """ As different users, request membership info for team_1_in_private_teamset_1. Only staff or users enrolled in a private_managed team should be able to tell that the team exists. (a bad team_id returns a 404 currently) TODO: No data is returned that shouldn't be, but the 403 that the users get tells them that a team with the given id does in fact exist. This should be changed to be a 404. """ memberships = self.get_membership_list( expected_response, {'team_id': self.team_1_in_private_teamset_1.team_id}, user=user ) if expected_response == 200: users = [membership['user']['username'] for membership in memberships['results']] assert users == ['student_on_team_1_private_set_1'] @ddt.data( ('student_enrolled_both_courses_other_team', 'TestX/TS101/Test_Course', 200, 'Nuclear Team'), ('student_enrolled_both_courses_other_team', 'MIT/6.002x/Circuits', 200, 'Another Team'), ('student_enrolled', 'TestX/TS101/Test_Course', 200, 'Sólar team'), ('student_enrolled', 'MIT/6.002x/Circuits', 400, ''), ) @ddt.unpack def test_course_filter_with_username(self, user, course_id, status, team_name): membership = self.get_membership_list( status, { 'username': self.users[user], 'course_id': course_id }, user=user ) if status == 200: assert membership['count'] == 1 assert membership['results'][0]['team']['team_id'] == self.test_team_name_id_map[team_name].team_id @ddt.data( ('TestX/TS101/Test_Course', 200), ('MIT/6.002x/Circuits', 400), ) @ddt.unpack def test_course_filter_with_team_id(self, course_id, status): membership = self.get_membership_list(status, {'team_id': self.solar_team.team_id, 'course_id': course_id}) if status == 200: assert membership['count'] == 1 assert membership['results'][0]['team']['team_id'] == self.solar_team.team_id def test_nonexistent_user(self): response = self.get_membership_list(200, {'username': 'this-user-will-not-exist-&&&&#!^'}) assert response['count'] == 0 def test_bad_course_id(self): self.get_membership_list(404, {'course_id': 'no_such_course'}) def test_no_username_or_team_id(self): self.get_membership_list(400, {}) def test_bad_team_id(self): self.get_membership_list(404, {'team_id': 'no_such_team'}) def test_expand_private_user(self): # Use the default user which is already private because to year_of_birth is set result = self.get_membership_list(200, {'team_id': self.solar_team.team_id, 'expand': 'user'}) self.verify_expanded_private_user(result['results'][0]['user']) def test_expand_public_user(self): result = self.get_membership_list( 200, {'team_id': self.public_profile_team.team_id, 'expand': 'user'}, user='student_enrolled_public_profile' ) self.verify_expanded_public_user(result['results'][0]['user']) def test_expand_team(self): result = self.get_membership_list(200, {'team_id': self.solar_team.team_id, 'expand': 'team'}) self.verify_expanded_team(result['results'][0]['team']) @ddt.data(False, True) def test_filter_teamset(self, filter_username): other_username = self.create_and_enroll_student() self.solar_team.add_user(self.users[other_username]) filters = { 'teamset_id': self.solar_team.topic_id, 'course_id': str(self.test_course_1.id) } if filter_username: filters['username'] = other_username result = self.get_membership_list(200, filters) assert result['count'] == (1 if filter_username else 2) usernames = {enrollment['user']['username'] for enrollment in result['results']} assert other_username in usernames if not filter_username: assert 'student_enrolled' in usernames def test_filter_teamset_team_id(self): # team_id and teamset_id are mutually exclusive self.get_membership_list( 400, { 'team_id': self.solar_team.team_id, 'teamset_id': 'topic_0', 'course_id': 'TestX/TS101/Non_Existent_Course' } ) def test_filter_teamset_no_course(self): self.get_membership_list(400, {'teamset_id': 'topic_0'}) def test_filter_teamset_not_enrolled_in_course(self): self.get_membership_list( 404, { 'teamset_id': 'topic_0', 'course_id': str(self.test_course_1.id) }, user='student_unenrolled' ) def test_filter_teamset_course_nonexistant(self): self.get_membership_list(404, {'teamset_id': 'topic_0', 'course_id': 'TestX/TS101/Non_Existent_Course'}) def test_filter_teamset_teamset_nonexistant(self): self.get_membership_list(404, {'teamset_id': 'nonexistant', 'course_id': str(self.test_course_1.id)}) def test_filter_teamset_enrolled_in_course_but_no_team_access(self): # The requesting user is enrolled in the course, but the requested team is oraganization_protected and # the requesting user is outside of the bubble self.get_membership_list( 404, { 'teamset_id': 'private_topic_1_id', 'course_id': str(self.test_course_1.id), 'username': 'student_on_team_1_private_set_1' } ) @ddt.unpack @ddt.data( ('student_enrolled', 404, None), ('student_on_team_1_private_set_1', 200, {'student_on_team_1_private_set_1'}), ('student_on_team_2_private_set_1', 200, {'student_on_team_2_private_set_1'}), ('student_masters', 404, None), ('staff', 200, {'student_on_team_1_private_set_1', 'student_on_team_2_private_set_1'}) ) def test_access_filter_teamset__private_teamset(self, user, expected_response, expected_users): memberships = self.get_membership_list( expected_response, { 'teamset_id': 'private_topic_1_id', 'course_id': str(self.test_course_1.id), }, user=user ) if expected_response == 200: returned_users = {membership['user']['username'] for membership in memberships['results']} assert returned_users == expected_users @ddt.unpack @ddt.data( ('student_enrolled', 404), ('student_on_team_1_private_set_1', 404), ('student_on_team_2_private_set_1', 404), ('student_masters', 404), ('staff', 200) ) def test_access_filter_teamset__private_teamset__no_teams(self, user, expected_response): """ private_topic_no_teams has no teams in it, but staff should still get a 200 when requesting teamset memberships """ self.get_membership_list( expected_response, { 'teamset_id': 'private_topic_no_teams', 'course_id': str(self.test_course_1.id), }, user=user ) @ddt.unpack @ddt.data( ('student_unenrolled', 404, {}), ('student_enrolled_not_on_team', 200, {'student_enrolled'}), ('student_enrolled', 200, {'student_enrolled'}), ('student_masters', 200, {'student_masters'}), ('staff', 200, {'student_enrolled', 'student_masters'}) ) def test_access_filter_teamset__open_teamset(self, user, expected_response, expected_usernames): # topic_3 has no teams assert not CourseTeam.objects.filter(topic_id='topic_3').exists() memberships = self.get_membership_list( expected_response, { 'teamset_id': 'topic_3', 'course_id': str(self.test_course_1.id), }, user=user ) if expected_response == 200: assert memberships['count'] == 0 # topic_0 has teams assert CourseTeam.objects.filter(topic_id='topic_0').exists() memberships = self.get_membership_list( expected_response, { 'teamset_id': 'topic_0', 'course_id': str(self.test_course_1.id), }, user=user ) if expected_response == 200: returned_users = {membership['user']['username'] for membership in memberships['results']} assert returned_users == expected_usernames @ddt.ddt class TestCreateMembershipAPI(EventTestMixin, TeamAPITestCase): """Test cases for the membership creation endpoint.""" def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.teams.utils.tracker') @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 404), ('student_enrolled_not_on_team', 200), ('student_enrolled', 404), ('student_enrolled_both_courses_other_team', 404), ('staff', 200), ('course_staff', 200), ('community_ta', 200), ) @ddt.unpack def test_access(self, user, status): membership = self.post_create_membership( status, self.build_membership_data('student_enrolled_not_on_team', self.solar_team), user=user ) if status == 200: assert membership['user']['username'] == self.users['student_enrolled_not_on_team'].username assert membership['team']['team_id'] == self.solar_team.team_id memberships = self.get_membership_list(200, {'team_id': self.solar_team.team_id}) assert memberships['count'] == 2 add_method = 'joined_from_team_view' if user == 'student_enrolled_not_on_team' else 'added_by_another_user' self.assert_event_emitted( 'edx.team.learner_added', team_id=self.solar_team.team_id, user_id=self.users['student_enrolled_not_on_team'].id, add_method=add_method ) else: self.assert_no_events_were_emitted() def test_no_username(self): response = self.post_create_membership(400, {'team_id': self.solar_team.team_id}) assert 'username' in json.loads(response.content.decode('utf-8'))['field_errors'] def test_no_team(self): response = self.post_create_membership(400, {'username': self.users['student_enrolled_not_on_team'].username}) assert 'team_id' in json.loads(response.content.decode('utf-8'))['field_errors'] @ddt.data('staff', 'student_enrolled') def test_bad_team(self, user): self.post_create_membership( 404, self.build_membership_data_raw(self.users['student_enrolled'].username, 'no_such_team'), user=user ) def test_bad_username(self): self.post_create_membership( 404, self.build_membership_data_raw('no_such_user', self.solar_team.team_id), user='staff' ) @patch('lms.djangoapps.teams.api.is_instructor_managed_team', return_value=True) def test_staff_join_instructor_managed_team(self, *args): # pylint: disable=unused-argument self.post_create_membership( 200, self.build_membership_data_raw(self.users['staff'].username, self.solar_team.team_id), user='staff' ) @patch('lms.djangoapps.teams.api.is_instructor_managed_team', return_value=True) def test_student_join_instructor_managed_team(self, *args): # pylint: disable=unused-argument self.post_create_membership( 403, self.build_membership_data_raw(self.users['student_enrolled_not_on_team'].username, self.solar_team.team_id) ) @ddt.data( ('student_masters', 400, 'is already a member'), ('student_masters_not_on_team', 200, None), ('student_unenrolled', 404, None), ('student_enrolled', 404, None), ('student_enrolled_both_courses_other_team', 404, None), ('staff', 200, None), ) @ddt.unpack def test_join_organization_protected_team(self, user, expected_status, expected_message): """ As different users, attempt to join masters_only team. Only staff or users within the organization_protected bubble should be able to join the team. Anyone else should not be able to join or even tell that the team exists. """ response = self.post_create_membership( expected_status, self.build_membership_data_raw(self.users[user].username, self.masters_only_team.team_id), user=user ) if expected_message: assert expected_message in json.loads(response.content.decode('utf-8'))['developer_message'] @ddt.unpack @ddt.data( ('student_on_team_1_private_set_1', 403), ('student_unenrolled', 404), ('student_enrolled', 404), ('student_on_team_2_private_set_1', 404), ('student_masters', 404), ('staff', 200) ) def test_student_join_private_managed_team(self, user, expected_status): """ As different users, attempt to join private_managed team. Only staff should be able to add users to any managed teams. Anyone else should not be able to join, and only student_on_team_1_private_set_1 should be able to tell that the team exists at all. (A nonexistant team results in a 404) """ self.post_create_membership( expected_status, self.build_membership_data_raw(self.users[user].username, self.team_1_in_private_teamset_1.team_id), user=user ) @ddt.data('student_enrolled', 'staff', 'course_staff') def test_join_twice(self, user): response = self.post_create_membership( 400, self.build_membership_data('student_enrolled', self.solar_team), user=user ) assert 'already a member' in json.loads(response.content.decode('utf-8'))['developer_message'] def test_join_second_team_in_course(self): """ Behavior allows the same student to be enrolled in multiple teams, as long as they belong to different topics (teamsets) """ self.post_create_membership( 200, self.build_membership_data('student_enrolled_both_courses_other_team', self.solar_team), user='student_enrolled_both_courses_other_team' ) @ddt.data('staff', 'course_staff') def test_not_enrolled_in_team_course(self, user): response = self.post_create_membership( 400, self.build_membership_data('student_unenrolled', self.solar_team), user=user ) assert 'not enrolled' in json.loads(response.content.decode('utf-8'))['developer_message'] def test_over_max_team_size_in_course_2(self): response = self.post_create_membership( 400, self.build_membership_data('student_enrolled_other_course_not_on_team', self.another_team), user='student_enrolled_other_course_not_on_team' ) assert 'full' in json.loads(response.content.decode('utf-8'))['developer_message'] @ddt.ddt class TestDetailMembershipAPI(TeamAPITestCase): """Test cases for the membership detail endpoint.""" @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 404), ('student_enrolled_not_on_team', 200), ('student_enrolled', 200), ('staff', 200), ('course_staff', 200), ('community_ta', 200), ) @ddt.unpack def test_access(self, user, status): self.get_membership_detail( self.solar_team.team_id, self.users['student_enrolled'].username, status, user=user ) def test_bad_team(self): self.get_membership_detail('no_such_team', self.users['student_enrolled'].username, 404) def test_bad_username(self): self.get_membership_detail(self.solar_team.team_id, 'no_such_user', 404) def test_no_membership(self): self.get_membership_detail( self.solar_team.team_id, self.users['student_enrolled_not_on_team'].username, 404 ) def test_expand_private_user(self): # Use the default user which is already private because to year_of_birth is set result = self.get_membership_detail( self.solar_team.team_id, self.users['student_enrolled'].username, 200, {'expand': 'user'} ) self.verify_expanded_private_user(result['user']) def test_expand_public_user(self): result = self.get_membership_detail( self.public_profile_team.team_id, self.users['student_enrolled_public_profile'].username, 200, {'expand': 'user'}, user='student_enrolled_public_profile' ) self.verify_expanded_public_user(result['user']) def test_expand_team(self): result = self.get_membership_detail( self.solar_team.team_id, self.users['student_enrolled'].username, 200, {'expand': 'team'} ) self.verify_expanded_team(result['team']) @ddt.data( ('student_masters', 200), ('student_masters_not_on_team', 200), ('student_unenrolled', 404), ('student_enrolled', 404), ('student_enrolled_both_courses_other_team', 404), ('staff', 200), ) @ddt.unpack def test_organization_protected(self, user, expected_status): """ Users should not be able to see memberships for users in a different bubble than them """ self.get_membership_detail( self.masters_only_team.team_id, self.users['student_masters'].username, expected_status, user=user ) @ddt.unpack @ddt.data( ('student_on_team_1_private_set_1', 200), ('student_unenrolled', 404), ('student_enrolled', 404), ('student_on_team_2_private_set_1', 404), ('staff', 200) ) def test_private_managed_team(self, user, expected_status): """ Users should not be able to see memberships for users in private_managed teams that they are not a member of """ self.get_membership_detail( self.team_1_in_private_teamset_1.team_id, self.users['student_on_team_1_private_set_1'].username, expected_status, user=user ) def test_join_private_managed_teamset(self): """ A user who is not on a private team requests membership info about that team. They are added to the team and then try again. """ self.get_membership_detail( self.team_1_in_private_teamset_1.team_id, self.users['student_on_team_1_private_set_1'].username, 404, user='student_masters' ) self.team_1_in_private_teamset_1.add_user(self.users['student_masters']) self.get_membership_detail( self.team_1_in_private_teamset_1.team_id, self.users['student_on_team_1_private_set_1'].username, 200, user='student_masters' ) @ddt.ddt class TestDeleteMembershipAPI(EventTestMixin, TeamAPITestCase): """Test cases for the membership deletion endpoint.""" def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.teams.utils.tracker') @ddt.data( (None, 401), ('student_inactive', 401), ('student_unenrolled', 404), ('student_enrolled_not_on_team', 404), ('student_enrolled', 204), ('staff', 204), ('course_staff', 204), ('community_ta', 204), ) @ddt.unpack def test_access(self, user, status): self.delete_membership( self.solar_team.team_id, self.users['student_enrolled'].username, status, user=user ) if status == 204: self.assert_event_emitted( 'edx.team.learner_removed', team_id=self.solar_team.team_id, user_id=self.users['student_enrolled'].id, remove_method='removed_by_admin' ) else: self.assert_no_events_were_emitted() def test_leave_team(self): """ The key difference between this test and test_access above is that removal via "Edit Membership" and "Leave Team" emit different events despite hitting the same API endpoint, due to the 'admin' query string. """ url = reverse('team_membership_detail', args=[self.solar_team.team_id, self.users['student_enrolled'].username]) self.make_call(url, 204, 'delete', user='student_enrolled') self.assert_event_emitted( 'edx.team.learner_removed', team_id=self.solar_team.team_id, user_id=self.users['student_enrolled'].id, remove_method='self_removal' ) def test_bad_team(self): self.delete_membership('no_such_team', self.users['student_enrolled'].username, 404) def test_bad_username(self): self.delete_membership(self.solar_team.team_id, 'no_such_user', 404) def test_missing_membership(self): self.delete_membership(self.wind_team.team_id, self.users['student_enrolled'].username, 404) @patch('lms.djangoapps.teams.api.is_instructor_managed_team', return_value=True) def test_student_leave_instructor_managed_team(self, *args): # pylint: disable=unused-argument self.delete_membership( self.solar_team.team_id, self.users['student_enrolled'].username, 403, user='student_enrolled') @ddt.unpack @ddt.data( ('student_enrolled', 'student_masters', 404), ('student_enrolled', 'student_enrolled', 404), ('student_masters_not_on_team', 'student_masters', 404), ('student_masters_not_on_team', 'student_masters_not_on_team', 404), ('student_masters', 'student_masters', 204), ('staff', 'student_masters', 204), ('staff', 'staff', 404), ) def test_organization_protection_status(self, user, user_to_remove, expected_status): """ As different users, attempt to remove themselves or studet_masters from masters_only team. (only student_masters is actually on this team) Only staff and the student_masters should be able to remove, and users outside of the organization_protected bubble should not be able to tell that the team exists in any way. TODO: student_enrolled should not be able to tell that masters_only team exists, he should get a 404 on both calls """ self.delete_membership( self.masters_only_team.team_id, self.users[user_to_remove].username, expected_status, user=user ) @ddt.unpack @ddt.data( ('student_enrolled', 'student_on_team_1_private_set_1', 404), ('student_enrolled', 'student_enrolled', 404), ('student_on_team_1_private_set_1', 'student_on_team_1_private_set_1', 403), ('student_on_team_2_private_set_1', 'student_on_team_1_private_set_1', 404), ('student_on_team_2_private_set_1', 'student_on_team_2_private_set_1', 404), ('staff', 'student_on_team_1_private_set_1', 204), ('staff', 'staff', 404), ) def test_remove_user_from_private_teamset(self, user, user_to_remove, expected_status): """ As different users, attempt to remove themselves or student_on_team_1_private_set_1 from a private_managed team. (only student_on_team_1_private_set_1 is actually on this team) Only staff should be able to remove, and all users other than student_on_team_1_private_set_1 should not be able to tell that the team exists in any way. TODO: The only 403 that should remain is student_on_team_1_private_set_1. The other users should not be able to tell that the team exists. """ self.delete_membership( self.team_1_in_private_teamset_1.team_id, self.users[user_to_remove].username, expected_status, user=user ) class TestElasticSearchErrors(TeamAPITestCase): """Test that the Team API is robust to Elasticsearch connection errors.""" ES_ERROR = ConnectionError('N/A', 'connection error', {}) @patch.object(SearchEngine, 'get_search_engine', side_effect=ES_ERROR) def test_list_teams(self, __): """Test that text searches return a 503 when Elasticsearch is down. The endpoint should still return 200 when a search is not supplied.""" self.get_teams_list( expected_status=503, data={'course_id': str(self.test_course_1.id), 'text_search': 'zoinks'}, user='staff' ) self.get_teams_list( expected_status=200, data={'course_id': str(self.test_course_1.id)}, user='staff' ) @patch.object(SearchEngine, 'get_search_engine', side_effect=ES_ERROR) def test_create_team(self, __): """Test that team creation is robust to Elasticsearch errors.""" self.post_create_team( expected_status=200, data=self.build_team_data(name='zoinks'), user='staff' ) @patch.object(SearchEngine, 'get_search_engine', side_effect=ES_ERROR) def test_delete_team(self, __): """Test that team deletion is robust to Elasticsearch errors.""" self.delete_team(self.wind_team.team_id, 204, user='staff') @patch.object(SearchEngine, 'get_search_engine', side_effect=ES_ERROR) def test_patch_team(self, __): """Test that team updates are robust to Elasticsearch errors.""" self.patch_team_detail( self.wind_team.team_id, 200, data={'description': 'new description'}, user='staff' ) @ddt.ddt class TestBulkMembershipManagement(TeamAPITestCase): """ Test that CSVs can be uploaded and downloaded to manage course membership. This test case will be expanded when the view is fully implemented (TODO MST-31). """ good_course_id = 'TestX/TS101/Test_Course' fake_course_id = 'TestX/TS101/Non_Existent_Course' allow_username = 'course_staff' deny_username = 'student_enrolled' @ddt.data( ('GET', good_course_id, deny_username, 403), ('GET', fake_course_id, allow_username, 404), ('GET', fake_course_id, deny_username, 404), ('POST', good_course_id, deny_username, 403), ) @ddt.unpack def test_error_statuses(self, method, course_id, username, expected_status): url = self.get_url(course_id) self.login(username) response = self.client.generic(method, url) assert response.status_code == expected_status def test_download_csv(self): url = self.get_url(self.good_course_id) self.login(self.allow_username) response = self.client.get(url) assert response.status_code == 200 assert response['Content-Type'] == 'text/csv' assert response['Content-Disposition'] == ( 'attachment; filename="team-membership_TestX_TS101_Test_Course.csv"' ) # For now, just assert that the file is non-empty. # Eventually, we will test contents (TODO MST-31). assert response.content @staticmethod def get_url(course_id): # This strategy allows us to test with invalid course IDs return reverse('team_membership_bulk_management', args=[course_id]) def test_create_membership_via_upload(self): self.create_and_enroll_student(username='a_user') csv_content = 'user,mode,topic_0' + '\n' csv_content += 'a_user,audit,team wind power' 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]), 201, method='post', data={'csv': csv_file}, user='staff' ) response_text = json.loads(response.content.decode('utf-8')) assert response_text['message'] == '1 learners were affected.' def test_upload_invalid_teamset(self): self.create_and_enroll_student(username='a_user') csv_content = 'user,mode,topic_0_bad' + '\n' csv_content += 'a_user,audit,team wind power' 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) self.make_call( reverse('team_membership_bulk_management', args=[self.good_course_id]), 400, method='post', data={'csv': csv_file}, user='staff' ) def test_upload_assign_user_twice_to_same_teamset(self): csv_content = 'user,mode,topic_0' + '\n' csv_content += 'student_enrolled, masters, team wind power' 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) self.make_call( reverse('team_membership_bulk_management', args=[self.good_course_id]), 400, method='post', data={'csv': csv_file}, user='staff' ) def test_upload_assign_one_user_to_different_teamsets(self): self.create_and_enroll_student(username='a_user') self.create_and_enroll_student(username='b_user') self.create_and_enroll_student(username='c_user') csv_content = 'user,mode,topic_0,topic_1,topic_2' + '\n' csv_content += 'a_user,audit,team wind power,team 2' + '\n' csv_content += 'b_user,audit,,team 2' + '\n' csv_content += 'c_user,audit,,,team 3' 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]), 201, method='post', data={'csv': csv_file}, user='staff') assert CourseTeam.objects.filter(name='team 2', course_id=self.test_course_1.id).count() == 1 response_text = json.loads(response.content.decode('utf-8')) assert response_text['message'] == '3 learners were affected.' def test_upload_non_existing_user(self): csv_content = 'user,mode,topic_0' + '\n' csv_content += 'missing_user, masters, team wind power' 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) self.make_call(reverse('team_membership_bulk_management', args=[self.good_course_id]), 400, method='post', data={'csv': csv_file}, user='staff' ) def test_upload_only_existing_courses(self): self.create_and_enroll_student(username='a_user', mode=CourseMode.MASTERS) self.create_and_enroll_student(username='b_user', mode=CourseMode.MASTERS) existing_team_1 = CourseTeamFactory.create( course_id=self.test_course_1.id, topic_id='topic_1', organization_protected=True ) existing_team_2 = CourseTeamFactory.create( course_id=self.test_course_1.id, topic_id='topic_2', organization_protected=True ) csv_content = 'user,mode,topic_1,topic_2' + '\n' csv_content += 'a_user,masters,{},{}'.format( existing_team_1.name, existing_team_2.name ) + '\n' csv_content += 'b_user,masters,{},{}'.format( existing_team_1.name, existing_team_2.name ) + '\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) self.make_call( reverse('team_membership_bulk_management', args=[self.good_course_id]), 201, method='post', data={'csv': csv_file}, user='staff' ) def test_upload_invalid_header(self): self.create_and_enroll_student(username='a_user') csv_content = 'mode,topic_1' + '\n' csv_content += 'a_user,audit, team wind power' 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) self.make_call(reverse( 'team_membership_bulk_management', args=[self.good_course_id]), 400, method='post', data={'csv': csv_file}, user='staff' ) def test_upload_invalid_more_teams_than_teamsets(self): self.create_and_enroll_student(username='a_user') csv_content = 'user,mode,topic_1' + '\n' csv_content += 'a_user, masters, team wind power, extra1, extra2' 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) self.make_call(reverse( 'team_membership_bulk_management', args=[self.good_course_id]), 400, method='post', data={'csv': csv_file}, user='staff' ) def test_upload_invalid_student_enrollment_mismatch(self): self.create_and_enroll_student(username='a_user', mode=CourseMode.AUDIT) csv_content = 'user,mode,topic_1' + '\n' csv_content += 'a_user,masters,team wind power' 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) self.make_call(reverse( 'team_membership_bulk_management', args=[self.good_course_id]), 400, method='post', 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 += f'{audit_username},audit,team wind power' + '\n' csv_content += f'{masters_username_a},masters,team wind power' + '\n' csv_content += f'{masters_username_b},masters,team wind power' + '\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.' assert 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' team2 = 'team 2' for name_enum in enumerate(['a', 'b', 'c', 'd', 'e', 'f', 'g']): username = 'user_{}'.format(name_enum[1]) self.create_and_enroll_student(username=username, mode=CourseMode.MASTERS) csv_content += f'{username},masters,{team1},{team2}' + '\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')) assert response_text['errors'][0] == 'New membership for team {} would exceed max size of {}.'.format(team1, 3) def test_deletion_via_upload_csv(self): # create a team membership that will be used further down self.test_create_membership_via_upload() username = 'a_user' topic_0_id = 'topic_0' assert CourseTeamMembership.objects.filter(user_id=self.users[username].id, team__topic_id=topic_0_id).exists() csv_content = f'user,mode,{topic_0_id},topic_1' + '\n' csv_content += f'{username},audit' 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) self.make_call( reverse('team_membership_bulk_management', args=[self.good_course_id]), 201, method='post', data={'csv': csv_file}, user='staff' ) assert not CourseTeamMembership.objects\ .filter(user_id=self.users[username].id, team__topic_id=topic_0_id).exists() def test_reassignment_via_upload_csv(self): # create a team membership that will be used further down self.test_create_membership_via_upload() username = 'a_user' topic_0_id = 'topic_0' nuclear_team_name = 'team nuclear power' windpower_team_name = 'team wind power' assert CourseTeamMembership.objects\ .filter(user_id=self.users[username].id, team__topic_id=topic_0_id, team__name=windpower_team_name).exists() csv_content = f'user,mode,{topic_0_id}' + '\n' csv_content += f'{username},audit,{nuclear_team_name}' 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) self.make_call( reverse('team_membership_bulk_management', args=[self.good_course_id]), 201, method='post', data={'csv': csv_file}, user='staff' ) assert not CourseTeamMembership.objects.filter(user_id=self.users[username].id, team__topic_id=topic_0_id, team__name=windpower_team_name).exists() assert CourseTeamMembership.objects.filter(user_id=self.users[username].id, team__topic_id=topic_0_id, team__name=nuclear_team_name).exists() def test_upload_file_not_changed_csv(self): # create a team membership that will be used further down self.test_create_membership_via_upload() username = 'a_user' topic_0_id = 'topic_0' nuclear_team_name = 'team wind power' assert len(CourseTeamMembership.objects.filter(user_id=self.users[username].id, team__topic_id=topic_0_id)) == 1 csv_content = f'user,mode,{topic_0_id}' + '\n' csv_content += f'{username},audit,{nuclear_team_name}' 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) self.make_call( reverse('team_membership_bulk_management', args=[self.good_course_id]), 201, method='post', data={'csv': csv_file}, user='staff' ) assert len(CourseTeamMembership.objects.filter(user_id=self.users[username].id, team__name=nuclear_team_name)) == 1 assert CourseTeamMembership.objects.filter(user_id=self.users[username].id, team__name=nuclear_team_name).exists() def test_create_membership_via_upload_using_external_key(self): self.create_and_enroll_student(username='a_user', external_key='a_user_external_key') csv_content = 'user,mode,topic_0' + '\n' csv_content += 'a_user_external_key,audit,team wind power' 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]), 201, method='post', data={'csv': csv_file}, user='staff' ) response_text = json.loads(response.content.decode('utf-8')) assert response_text['message'] == '1 learners were affected.' def test_create_membership_via_upload_using_external_key_invalid(self): self.create_and_enroll_student(username='a_user', external_key='a_user_external_key') csv_content = 'user,mode,topic_0' + '\n' csv_content += 'a_user_external_key_invalid,audit,team wind power' 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')) assert response_text['errors'] == ['User name/email/external key: a_user_external_key_invalid does not exist.'] def test_upload_non_ascii(self): csv_content = 'user,mode,topic_0' + '\n' team_name = '著文企臺個' user_name = '著著文企臺個文企臺個' self.create_and_enroll_student(username=user_name) csv_content += f'{user_name},audit,{team_name}' 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) self.make_call( reverse('team_membership_bulk_management', args=[self.good_course_id]), 201, method='post', data={'csv': csv_file}, user='staff' ) team = self.users[user_name].teams.first() assert team.name == team_name assert [user.username for user in team.users.all()] == [user_name] def test_upload_assign_masters_learner_to_non_protected_team(self): """ Scenario: Attempt to add a learner enrolled in masters track to an existing, non-org protected team. Outcome: Must fail """ masters_a = 'masters_a' team = self.wind_team self.create_and_enroll_student(username=masters_a, mode=CourseMode.MASTERS) csv_content = f'user,mode,{team.topic_id}' + '\n' csv_content += f'masters_a, masters,{team.name}' 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_message = 'Team {} cannot have Master’s track users mixed with users in other tracks.'.format( team.name ) assert response_text['errors'][0] == expected_message