Skip to content

Commit 09878a1

Browse files
fix: Limit roles that can be seen in Instructor Dashboard. (#38460)
1 parent 915709a commit 09878a1

2 files changed

Lines changed: 89 additions & 14 deletions

File tree

lms/djangoapps/instructor/tests/test_api_v2.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3563,28 +3563,55 @@ def test_forum_admin_can_list_team_members(self):
35633563
response = self.client.get(url)
35643564
assert response.status_code == status.HTTP_200_OK
35653565

3566-
def test_forum_admin_can_grant_role(self):
3567-
"""Discussion Admin should be able to POST /team to grant a role."""
3566+
def test_forum_admin_can_grant_forum_role(self):
3567+
"""Discussion Admin should be able to grant a forum role."""
35683568
url = reverse('instructor_api_v2:course_team', kwargs={'course_id': str(self.course_key)})
35693569
target = UserFactory.create()
35703570
self.client.force_authenticate(user=self.forum_admin)
35713571
response = self.client.post(url, {
35723572
'identifiers': [target.username],
3573-
'role': 'staff',
3573+
'role': 'Moderator',
35743574
'action': 'allow',
35753575
}, format='json')
35763576
assert response.status_code == status.HTTP_200_OK
35773577

3578-
def test_forum_admin_can_revoke_role(self):
3579-
"""Discussion Admin should be able to DELETE /team/{username}."""
3578+
def test_forum_admin_can_revoke_forum_role(self):
3579+
"""Discussion Admin should be able to revoke a forum role."""
3580+
target = UserFactory.create()
3581+
role = Role.objects.get(course_id=self.course_key, name='Moderator')
3582+
role.users.add(target)
3583+
url = reverse(
3584+
'instructor_api_v2:course_team_member',
3585+
kwargs={'course_id': str(self.course_key), 'email_or_username': target.username},
3586+
)
3587+
self.client.force_authenticate(user=self.forum_admin)
3588+
response = self.client.delete(url, {'roles': ['Moderator']}, format='json')
3589+
assert response.status_code == status.HTTP_200_OK
3590+
3591+
def test_forum_admin_cannot_grant_course_role(self):
3592+
"""Discussion Admin should not be able to grant a non-forum course role like staff."""
3593+
url = reverse('instructor_api_v2:course_team', kwargs={'course_id': str(self.course_key)})
3594+
target = UserFactory.create()
3595+
self.client.force_authenticate(user=self.forum_admin)
3596+
response = self.client.post(url, {
3597+
'identifiers': [target.username],
3598+
'role': 'staff',
3599+
'action': 'allow',
3600+
}, format='json')
3601+
assert response.status_code == status.HTTP_403_FORBIDDEN
3602+
assert 'You do not have permissions to change this role' in response.data['error']
3603+
3604+
def test_forum_admin_cannot_revoke_course_role(self):
3605+
"""Discussion Admin should not be able to revoke a non-forum course role like staff."""
35803606
target = StaffFactory.create(course_key=self.course_key)
35813607
url = reverse(
35823608
'instructor_api_v2:course_team_member',
35833609
kwargs={'course_id': str(self.course_key), 'email_or_username': target.username},
35843610
)
35853611
self.client.force_authenticate(user=self.forum_admin)
35863612
response = self.client.delete(url, {'roles': ['staff']}, format='json')
3587-
assert response.status_code == status.HTTP_200_OK
3613+
assert response.status_code == status.HTTP_403_FORBIDDEN
3614+
assert 'You do not have permissions to change the requested roles' in response.data['error']
35883615

35893616
def test_plain_staff_cannot_access_team_endpoints(self):
35903617
"""Staff without instructor or forum admin role should get 403."""
@@ -3615,10 +3642,10 @@ def test_forum_admin_cannot_grant_instructor_role(self):
36153642
'action': 'allow',
36163643
}, format='json')
36173644
assert response.status_code == status.HTTP_403_FORBIDDEN
3645+
assert 'You do not have permissions to change this role' in response.data['error']
36183646

36193647
def test_forum_admin_cannot_revoke_instructor_role(self):
36203648
"""Discussion Admin should not be able to revoke the instructor role."""
3621-
# Create an instructor to target
36223649
instructor = InstructorFactory.create(course_key=self.course_key)
36233650
url = reverse(
36243651
'instructor_api_v2:course_team_member',
@@ -3627,3 +3654,38 @@ def test_forum_admin_cannot_revoke_instructor_role(self):
36273654
self.client.force_authenticate(user=self.forum_admin)
36283655
response = self.client.delete(url, {'roles': ['instructor']}, format='json')
36293656
assert response.status_code == status.HTTP_403_FORBIDDEN
3657+
assert 'You do not have permissions to change the requested roles' in response.data['error']
3658+
3659+
def test_roles_editable_param_filters_for_forum_admin(self):
3660+
"""GET /team/roles?editable=true returns only forum roles for Discussion Admin."""
3661+
url = reverse('instructor_api_v2:course_team_roles', kwargs={'course_id': str(self.course_key)})
3662+
self.client.force_authenticate(user=self.forum_admin)
3663+
response = self.client.get(url, {'editable': 'true'})
3664+
assert response.status_code == status.HTTP_200_OK
3665+
returned_roles = {r['role'] for r in response.data['results']}
3666+
assert returned_roles == {'Administrator', 'Moderator', 'Group Moderator', 'Community TA'}
3667+
3668+
def test_roles_editable_param_returns_all_for_instructor(self):
3669+
"""GET /team/roles?editable=true returns all roles for an instructor."""
3670+
instructor = InstructorFactory.create(course_key=self.course_key)
3671+
url = reverse('instructor_api_v2:course_team_roles', kwargs={'course_id': str(self.course_key)})
3672+
self.client.force_authenticate(user=instructor)
3673+
response = self.client.get(url, {'editable': 'true'})
3674+
assert response.status_code == status.HTTP_200_OK
3675+
returned_roles = {r['role'] for r in response.data['results']}
3676+
# Instructor should see both course roles and forum roles
3677+
assert 'instructor' in returned_roles
3678+
assert 'staff' in returned_roles
3679+
assert 'Administrator' in returned_roles
3680+
3681+
def test_roles_without_editable_param_returns_all(self):
3682+
"""GET /team/roles without editable param returns all roles regardless of user."""
3683+
url = reverse('instructor_api_v2:course_team_roles', kwargs={'course_id': str(self.course_key)})
3684+
self.client.force_authenticate(user=self.forum_admin)
3685+
response = self.client.get(url)
3686+
assert response.status_code == status.HTTP_200_OK
3687+
returned_roles = {r['role'] for r in response.data['results']}
3688+
# Without editable param, all roles are returned
3689+
assert 'instructor' in returned_roles
3690+
assert 'staff' in returned_roles
3691+
assert 'Administrator' in returned_roles

lms/djangoapps/instructor/views/api_v2.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3065,9 +3065,15 @@ class CourseTeamRolesView(DeveloperErrorViewMixin, APIView):
30653065
``CUSTOM_COURSES_EDX`` feature flag is enabled **and** the course
30663066
has CCX enabled (``course.enable_ccx``).
30673067
3068+
When the `editable=true` query parameter is passed, the results
3069+
are further filtered to only include roles the requesting user has
3070+
permission to assign. Discussion Administrators will only see forum
3071+
roles; instructors will see all roles.
3072+
30683073
**GET Example Request**
30693074
30703075
GET /api/instructor/v2/courses/{course_id}/team/roles
3076+
GET /api/instructor/v2/courses/{course_id}/team/roles?editable=true
30713077
30723078
**GET Response Values**
30733079
@@ -3095,12 +3101,17 @@ def get(self, request, course_id):
30953101
course_key = CourseKey.from_string(course_id)
30963102
course = get_course_by_id(course_key)
30973103

3104+
editable = request.query_params.get('editable', 'false').lower() == 'true'
3105+
30983106
roles = set(ROLES.keys()) | set(FORUM_ROLES)
30993107

31003108
ccx_enabled = settings.FEATURES.get('CUSTOM_COURSES_EDX', False) and course.enable_ccx
31013109
if not ccx_enabled:
31023110
roles.discard('ccx_coach')
31033111

3112+
if editable and not has_access(request.user, 'instructor', course):
3113+
roles = set(FORUM_ROLES)
3114+
31043115
results = [
31053116
{'role': rolename, 'display_name': str(ROLE_DISPLAY_NAMES[rolename])}
31063117
for rolename in sorted(roles)
@@ -3299,9 +3310,9 @@ def post(self, request, course_id):
32993310
rolename = serializer.validated_data['role']
33003311
action = serializer.validated_data['action']
33013312

3302-
if rolename == 'instructor' and not has_access(request.user, 'instructor', course):
3313+
if not is_forum_role(rolename) and not has_access(request.user, 'instructor', course):
33033314
return Response(
3304-
{'error': _('Managing the instructor role requires instructor access.')},
3315+
{'error': _('You do not have permissions to change this role.')},
33053316
status=status.HTTP_403_FORBIDDEN,
33063317
)
33073318

@@ -3401,11 +3412,13 @@ def delete(self, request, course_id, email_or_username):
34013412

34023413
roles = revoke_serializer.validated_data['roles']
34033414

3404-
if 'instructor' in roles and not has_access(request.user, 'instructor', course):
3405-
return Response(
3406-
{'error': _('Managing the instructor role requires instructor access.')},
3407-
status=status.HTTP_403_FORBIDDEN,
3408-
)
3415+
if not has_access(request.user, 'instructor', course):
3416+
non_forum_roles = [r for r in roles if not is_forum_role(r)]
3417+
if non_forum_roles:
3418+
return Response(
3419+
{'error': _('You do not have permissions to change the requested roles.')},
3420+
status=status.HTTP_403_FORBIDDEN,
3421+
)
34093422

34103423
try:
34113424
user = get_student_from_identifier(email_or_username)

0 commit comments

Comments
 (0)