diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index 08f818aa4d19..534006f6b594 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -12,6 +12,9 @@ import logging +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ + from common.djangoapps.student.roles import ( CourseBetaTesterRole, CourseCcxCoachRole, @@ -21,7 +24,13 @@ CourseStaffRole, ) from lms.djangoapps.instructor.enrollment import enroll_email, get_email_params -from openedx.core.djangoapps.django_comment_common.models import Role +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_MODERATOR, + Role, +) log = logging.getLogger(__name__) @@ -34,6 +43,47 @@ 'data_researcher': CourseDataResearcherRole, } +#: Forum/discussion roles managed through :func:`update_forum_role`. +#: Stored separately from :data:`ROLES` because they use a different +#: model (``Role`` from django_comment_common) and different helpers. +FORUM_ROLES = ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, +) + +ROLE_DISPLAY_NAMES = { + 'instructor': _('Instructor'), + 'staff': _('Staff'), + 'limited_staff': _('Limited Staff'), + 'beta': _('Beta Tester'), + 'ccx_coach': _('CCX Coach'), + 'data_researcher': _('Data Researcher'), + FORUM_ROLE_ADMINISTRATOR: _('Discussion Admin'), + FORUM_ROLE_MODERATOR: _('Discussion Moderator'), + FORUM_ROLE_GROUP_MODERATOR: _('Group Moderator'), + FORUM_ROLE_COMMUNITY_TA: _('Community TA'), +} + + +def is_forum_role(rolename): + """Return True if ``rolename`` is a forum/discussion role.""" + return rolename in FORUM_ROLES + + +def list_forum_members(course_id, rolename): + """ + Return a User QuerySet of users holding ``rolename`` forum role for the course. + + Returns an empty QuerySet if the role doesn't exist for the course. + """ + try: + role = Role.objects.get(course_id=course_id, name=rolename) + except Role.DoesNotExist: + return get_user_model().objects.none() + return role.users.all() + def list_with_level(course_id, level): """ diff --git a/lms/djangoapps/instructor/docs/references/instructor-v2-course-team-api-spec.yaml b/lms/djangoapps/instructor/docs/references/instructor-v2-course-team-api-spec.yaml new file mode 100644 index 000000000000..b693c68908ef --- /dev/null +++ b/lms/djangoapps/instructor/docs/references/instructor-v2-course-team-api-spec.yaml @@ -0,0 +1,708 @@ +swagger: '2.0' +info: + title: Instructor Dashboard Course Team API + version: 2.0.0 + description: | + Modern REST API for managing course team members (instructors, staff, beta testers, + CCX coaches, and discussion/forum roles) on the instructor dashboard. + + **Design Principles:** + - RESTful resource-oriented URLs + - Query parameters for filtering operations + - Clear separation between read and write operations + - Consistent error handling + + **Execution Model:** + - All operations execute synchronously + - Single-user modifications are immediate (~100-500ms) + - Bulk beta tester operations process each identifier and return aggregated results + + **Authorization:** + - Listing and modifying course team members requires instructor-level access + - Bulk beta tester management requires beta test permission + - Instructors cannot remove their own instructor access + +host: courses.example.com +basePath: / +schemes: + - https + +securityDefinitions: + JWTAuth: + type: apiKey + in: header + name: Authorization + description: JWT token authentication. Header format depends on JWT_AUTH['JWT_AUTH_HEADER_PREFIX'] setting (default is 'JWT '). + +security: + - JWTAuth: [] + +tags: + - name: Course Team + description: Course team role membership management + - name: Beta Testers + description: Bulk beta tester management + +paths: + # ==================== COURSE TEAM ENDPOINTS ==================== + + /api/instructor/v2/courses/{course_key}/team: + get: + tags: + - Course Team + summary: List course team members + description: | + Retrieve a list of users who hold course team roles. + + When `role` is provided, returns only members with that role. + When `role` is omitted, returns all team members across all roles. + Each result includes a `role` field so the caller knows which role + the user holds. + + **Available Roles:** + - `instructor` — Full admin, can manage other roles + - `staff` — Can view/edit content, limited role management + - `limited_staff` — LMS-only staff + - `beta` — Beta testers + - `ccx_coach` — CCX coach (only when CCX is enabled) + - `data_researcher` — Data researcher + - `Administrator` — Discussion administrator (forum role) + - `Moderator` — Discussion moderator (forum role) + - `Group Moderator` — Group moderator (forum role) + - `Community TA` — Community TA (forum role) + + The `search` query parameter performs a case-insensitive substring + match against username, email, first name, and last name. + + **Note:** This replaces the legacy `POST /courses/{course_id}/instructor/api/list_course_role_members` endpoint. + operationId: listCourseTeamMembers + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseKey' + - name: role + in: query + description: | + Filter by role. When omitted, members across all roles are returned. + required: false + type: string + enum: + - instructor + - staff + - limited_staff + - beta + - ccx_coach + - data_researcher + - Administrator + - Moderator + - Group Moderator + - Community TA + x-example: "staff" + - name: search + in: query + description: | + Case-insensitive substring filter applied to username, email, + first name, and last name of results. + required: false + type: string + x-example: "jane" + responses: + 200: + description: Team members retrieved successfully + schema: + $ref: '#/definitions/CourseTeamMemberList' + examples: + application/json: + course_id: "course-v1:edX+DemoX+Demo_Course" + role: "staff" + search: null + results: + - username: "staff_user1" + email: "staff1@example.com" + first_name: "Alice" + last_name: "Smith" + role: "staff" + - username: "staff_user2" + email: "staff2@example.com" + first_name: "Bob" + last_name: "Jones" + role: "staff" + 400: + $ref: '#/responses/BadRequest' + 401: + $ref: '#/responses/Unauthorized' + 403: + $ref: '#/responses/Forbidden' + 404: + $ref: '#/responses/NotFound' + + post: + tags: + - Course Team + summary: Grant a course role to a user + description: | + Grant a specific course role to a user, identified by username or email. + + **Behavior:** + - If the user is not already enrolled in the course, they will be auto-enrolled + - For `ccx_coach` role, an enrollment email is sent automatically + - The user must exist and be active + + **Note:** This replaces the `action=allow` path of the legacy + `POST /courses/{course_id}/instructor/api/modify_access` endpoint. + operationId: grantCourseRole + consumes: + - application/json + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseKey' + - name: body + in: body + required: true + schema: + type: object + required: + - identifier + - role + properties: + identifier: + type: string + description: Username or email of the user to grant the role to + example: "jane_doe" + role: + type: string + description: The role to grant (course access role or forum role) + enum: + - instructor + - staff + - limited_staff + - beta + - ccx_coach + - data_researcher + - Administrator + - Moderator + - Group Moderator + - Community TA + example: "staff" + responses: + 201: + description: Role granted successfully + schema: + $ref: '#/definitions/CourseTeamModifyResult' + examples: + application/json: + identifier: "jane_doe" + role: "staff" + action: "allow" + success: true + 400: + $ref: '#/responses/BadRequest' + 401: + $ref: '#/responses/Unauthorized' + 403: + $ref: '#/responses/Forbidden' + 404: + $ref: '#/responses/NotFound' + + /api/instructor/v2/courses/{course_key}/team/{identifier}: + delete: + tags: + - Course Team + summary: Revoke a course role from a user + description: | + Revoke a specific course role from a user. + + **Constraints:** + - Instructors cannot revoke their own instructor access + - The user must exist and be active + + **Note:** This replaces the `action=revoke` path of the legacy + `POST /courses/{course_id}/instructor/api/modify_access` endpoint. + operationId: revokeCourseRole + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseKey' + - name: identifier + in: path + description: Username or email of the user to revoke the role from + required: true + type: string + x-example: "jane_doe" + - name: role + in: query + description: The role to revoke (course access role or forum role) + required: true + type: string + enum: + - instructor + - staff + - limited_staff + - beta + - ccx_coach + - data_researcher + - Administrator + - Moderator + - Group Moderator + - Community TA + x-example: "staff" + responses: + 200: + description: Role revoked successfully + schema: + $ref: '#/definitions/CourseTeamModifyResult' + examples: + application/json: + identifier: "jane_doe" + role: "staff" + action: "revoke" + success: true + 400: + $ref: '#/responses/BadRequest' + 401: + $ref: '#/responses/Unauthorized' + 403: + $ref: '#/responses/Forbidden' + 404: + $ref: '#/responses/NotFound' + 409: + description: Cannot remove own instructor access + schema: + type: object + required: + - error + properties: + error: + type: string + description: Error message explaining why the instructor role cannot be revoked + examples: + application/json: + error: "Instructors cannot remove their own instructor access." + + # ==================== COURSE TEAM ROLES ENDPOINT ==================== + + /api/instructor/v2/courses/{course_key}/team/roles: + get: + tags: + - Course Team + summary: List available course team roles + description: | + Returns the list of roles that can be assigned for this course. + The list is dynamic — roles that are not applicable to the course + are excluded. For example, `ccx_coach` is only included when the + `CUSTOM_COURSES_EDX` feature flag is enabled **and** the course + has CCX enabled (`course.enable_ccx`). + + **Note:** This endpoint has no legacy equivalent. + operationId: listCourseTeamRoles + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseKey' + responses: + 200: + description: Available roles retrieved successfully + schema: + $ref: '#/definitions/CourseTeamRolesList' + examples: + application/json: + course_id: "course-v1:edX+DemoX+Demo_Course" + results: + - role: "beta" + display_name: "Beta Tester" + - role: "data_researcher" + display_name: "Data Researcher" + - role: "instructor" + display_name: "Instructor" + - role: "limited_staff" + display_name: "Limited Staff" + - role: "staff" + display_name: "Staff" + 401: + $ref: '#/responses/Unauthorized' + 403: + $ref: '#/responses/Forbidden' + 404: + $ref: '#/responses/NotFound' + + # ==================== BULK BETA TESTER ENDPOINTS ==================== + + /api/instructor/v2/courses/{course_key}/team/beta-testers: + post: + tags: + - Beta Testers + summary: Bulk add beta testers + description: | + Add multiple users as beta testers for a course. + + **Behavior:** + - Processes each identifier independently; one failure does not block others + - If `auto_enroll` is true, users not already enrolled will be enrolled + - If `email_students` is true, notification emails are sent to each user + - Users that do not exist are reported as errors in the results + + **Note:** This replaces the `action=add` path of the legacy + `POST /courses/{course_id}/instructor/api/bulk_beta_modify_access` endpoint. + operationId: bulkAddBetaTesters + consumes: + - application/json + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseKey' + - name: body + in: body + required: true + schema: + type: object + required: + - identifiers + properties: + identifiers: + type: array + description: List of usernames or email addresses to add as beta testers + minItems: 1 + items: + type: string + example: ["beta_user1", "beta@example.com"] + auto_enroll: + type: boolean + description: Automatically enroll users who are not already enrolled + default: false + email_students: + type: boolean + description: Send notification email to users + default: false + responses: + 200: + description: Bulk operation completed + schema: + $ref: '#/definitions/BulkBetaTesterResult' + examples: + application/json: + action: "add" + results: + - identifier: "beta_user1" + error: false + user_does_not_exist: false + is_active: true + - identifier: "unknown@example.com" + error: true + user_does_not_exist: true + is_active: null + 400: + $ref: '#/responses/BadRequest' + 401: + $ref: '#/responses/Unauthorized' + 403: + $ref: '#/responses/Forbidden' + 404: + $ref: '#/responses/NotFound' + + delete: + tags: + - Beta Testers + summary: Bulk remove beta testers + description: | + Remove multiple users from the beta tester role for a course. + + **Behavior:** + - Processes each identifier independently; one failure does not block others + - If `email_students` is true, notification emails are sent to each user + - Users that do not exist are reported as errors in the results + + **Note:** This replaces the `action=remove` path of the legacy + `POST /courses/{course_id}/instructor/api/bulk_beta_modify_access` endpoint. + operationId: bulkRemoveBetaTesters + consumes: + - application/json + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseKey' + - name: body + in: body + required: true + schema: + type: object + required: + - identifiers + properties: + identifiers: + type: array + description: List of usernames or email addresses to remove as beta testers + minItems: 1 + items: + type: string + example: ["beta_user1", "beta@example.com"] + email_students: + type: boolean + description: Send notification email to users + default: false + responses: + 200: + description: Bulk operation completed + schema: + $ref: '#/definitions/BulkBetaTesterResult' + examples: + application/json: + action: "remove" + results: + - identifier: "beta_user1" + error: false + user_does_not_exist: false + is_active: true + 400: + $ref: '#/responses/BadRequest' + 401: + $ref: '#/responses/Unauthorized' + 403: + $ref: '#/responses/Forbidden' + 404: + $ref: '#/responses/NotFound' + +# ==================== COMPONENTS ==================== + +parameters: + CourseKey: + name: course_key + in: path + required: true + description: Course identifier in format `course-v1:{org}+{course}+{run}` + type: string + pattern: '^course-v1:[^/+]+(\+[^/+]+)+(\+[^/]+)$' + x-example: "course-v1:edX+DemoX+Demo_Course" + +responses: + BadRequest: + description: Bad request - Invalid parameters or malformed request. Returns either + a simple error payload or serializer validation errors. + schema: + $ref: '#/definitions/Error' + examples: + application/json: + error: "The role query parameter is required." + + Unauthorized: + description: Unauthorized - Authentication required + schema: + $ref: '#/definitions/Error' + examples: + application/json: + detail: "Authentication credentials were not provided." + + Forbidden: + description: Forbidden - Insufficient permissions + schema: + $ref: '#/definitions/Error' + examples: + application/json: + detail: "You do not have permission to perform this action." + + NotFound: + description: Not found - Resource does not exist + schema: + $ref: '#/definitions/Error' + examples: + application/json: + error: "User 'unknown' not found." + +definitions: + CourseTeamRolesList: + type: object + description: List of available course team roles, filtered by course configuration + required: + - course_id + - results + properties: + course_id: + type: string + description: Course identifier + example: "course-v1:edX+DemoX+Demo_Course" + results: + type: array + description: | + Sorted list of available roles for this course, each with a + human-readable display name. The `ccx_coach` role is only + present when CCX is enabled. + items: + $ref: '#/definitions/CourseTeamRole' + + CourseTeamRole: + type: object + description: A single course team role with its display name + required: + - role + - display_name + properties: + role: + type: string + description: Machine-readable role identifier (course access role or forum role) + enum: + - beta + - ccx_coach + - data_researcher + - instructor + - limited_staff + - staff + - Administrator + - Moderator + - Group Moderator + - Community TA + example: "data_researcher" + display_name: + type: string + description: Human-readable role name, suitable for display in a UI + example: "Data Researcher" + + CourseTeamMemberList: + type: object + description: List of course team members, optionally filtered by role and/or search term + required: + - course_id + - role + - search + - results + properties: + course_id: + type: string + description: Course identifier + example: "course-v1:edX+DemoX+Demo_Course" + role: + type: string + description: The role that was queried, or null when all roles were requested + x-nullable: true + example: "staff" + search: + type: string + description: The search term that was applied, or null when no search filter was provided + x-nullable: true + example: "jane" + results: + type: array + description: List of team members + items: + $ref: '#/definitions/CourseTeamMember' + + CourseTeamMember: + type: object + description: A user with a course team role + required: + - username + - email + - first_name + - last_name + - role + properties: + username: + type: string + description: User's username + example: "staff_user1" + email: + type: string + format: email + description: User's email address + example: "staff1@example.com" + first_name: + type: string + description: User's first name + example: "Alice" + last_name: + type: string + description: User's last name + example: "Smith" + role: + type: string + description: The role this user holds in the course + example: "staff" + + CourseTeamModifyResult: + type: object + description: Result of granting or revoking a course role + required: + - identifier + - role + - action + - success + properties: + identifier: + type: string + description: Username of the affected user + example: "jane_doe" + role: + type: string + description: The role that was modified + example: "staff" + action: + type: string + enum: ["allow", "revoke"] + description: The action that was performed + example: "allow" + success: + type: boolean + description: Whether the operation succeeded + example: true + + BulkBetaTesterResult: + type: object + description: Aggregated result of a bulk beta tester operation + required: + - action + - results + properties: + action: + type: string + enum: ["add", "remove"] + description: The action that was performed + example: "add" + results: + type: array + description: Per-identifier results + items: + $ref: '#/definitions/BulkBetaTesterItemResult' + + BulkBetaTesterItemResult: + type: object + description: Result for a single identifier in a bulk beta tester operation + required: + - identifier + - error + - user_does_not_exist + - is_active + properties: + identifier: + type: string + description: The email or username that was processed + example: "beta_user1" + error: + type: boolean + description: Whether an error occurred processing this identifier + example: false + user_does_not_exist: + type: boolean + description: Whether the user was not found + example: false + is_active: + type: boolean + description: Whether the user account is active (null if user does not exist) + x-nullable: true + example: true + + Error: + type: object + description: | + Error response. The shape varies by error source: + - View-level errors return `{"error": "..."}` with a human-readable message. + - Serializer validation errors return field-keyed objects (e.g., `{"role": ["..."]}`). + - DRF permission/authentication errors return `{"detail": "..."}`. + properties: + error: + type: string + description: Human-readable error message (view-level errors) + example: "User 'unknown' not found." + detail: + type: string + description: Error message from DRF (authentication/permission errors) + example: "Authentication credentials were not provided." + additionalProperties: true diff --git a/lms/djangoapps/instructor/permissions.py b/lms/djangoapps/instructor/permissions.py index 254d1457b557..ca04c6dbc6c7 100644 --- a/lms/djangoapps/instructor/permissions.py +++ b/lms/djangoapps/instructor/permissions.py @@ -3,6 +3,8 @@ """ from bridgekeeper import perms from bridgekeeper.rules import is_staff +from django.http import Http404 +from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission @@ -83,7 +85,11 @@ class InstructorPermission(BasePermission): """Generic permissions""" def has_permission(self, request, view): - course = get_course_by_id(CourseKey.from_string(view.kwargs.get('course_id'))) + try: + course_key = CourseKey.from_string(view.kwargs.get('course_id')) + except InvalidKeyError as exc: + raise Http404('Invalid course key') from exc + course = get_course_by_id(course_key) permission = getattr(view, 'permission_name', None) return request.user.has_perm(permission, course) diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index b97267d5ceec..9e0275fa1dd3 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -8,17 +8,24 @@ from uuid import uuid4 import ddt +from django.conf import settings +from django.http import Http404 from django.test import SimpleTestCase, override_settings from django.urls import NoReverseMatch, reverse from edx_when.api import set_date_for_block, set_dates_for_course from opaque_keys import InvalidKeyError from pytz import UTC from rest_framework import status -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import APIClient, APIRequestFactory, APITestCase from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.models.course_enrollment import CourseEnrollment -from common.djangoapps.student.roles import CourseBetaTesterRole, CourseDataResearcherRole, CourseInstructorRole +from common.djangoapps.student.roles import ( + CourseBetaTesterRole, + CourseDataResearcherRole, + CourseInstructorRole, + CourseStaffRole, +) from common.djangoapps.student.tests.factories import ( AdminFactory, CourseEnrollmentFactory, @@ -30,8 +37,11 @@ from lms.djangoapps.certificates.models import CertificateGenerationHistory from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.courseware.models import StudentModule +from lms.djangoapps.instructor.permissions import InstructorPermission from lms.djangoapps.instructor.views.serializers_v2 import CourseInformationSerializerV2 from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory +from openedx.core.djangoapps.django_comment_common.models import Role +from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory @@ -2313,3 +2323,529 @@ def test_filter_beta_testers_with_search(self): data = response.data self.assertEqual(data['count'], 1) # noqa: PT009 self.assertTrue(data['results'][0]['is_beta_tester']) # noqa: PT009 + + +class CourseTeamRolesViewTest(SharedModuleStoreTestCase): + """Tests for CourseTeamRolesView (GET available roles) endpoint.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create( + org='edX', + number='RolesX', + run='2024', + display_name='Roles Test Course', + ) + cls.course_key = cls.course.id + + def setUp(self): + super().setUp() + self.client = APIClient() + self.instructor = InstructorFactory.create(course_key=self.course_key) + self.student = UserFactory.create() + self.url = reverse('instructor_api_v2:course_team_roles', kwargs={'course_id': str(self.course_key)}) + + def test_list_roles_without_ccx(self): + """Returns roles excluding ccx_coach when CCX is not enabled; includes forum roles.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['course_id'] == str(self.course_key) + returned_roles = [r['role'] for r in response.data['results']] + assert 'ccx_coach' not in returned_roles + # Course access roles + for expected in ['beta', 'data_researcher', 'instructor', 'limited_staff', 'staff']: + assert expected in returned_roles + # Forum roles + for expected in ['Administrator', 'Moderator', 'Group Moderator', 'Community TA']: + assert expected in returned_roles + + @override_settings(FEATURES={**settings.FEATURES, 'CUSTOM_COURSES_EDX': True}) + def test_list_roles_with_ccx_enabled(self): + """Returns all roles including ccx_coach when CCX is enabled for the course.""" + ccx_course = CourseFactory.create( + org='edX', + number='CcxX', + run='2024', + display_name='CCX Test Course', + enable_ccx=True, + ) + url = reverse('instructor_api_v2:course_team_roles', kwargs={'course_id': str(ccx_course.id)}) + instructor = InstructorFactory.create(course_key=ccx_course.id) + self.client.force_authenticate(user=instructor) + response = self.client.get(url) + + assert response.status_code == status.HTTP_200_OK + returned_roles = [r['role'] for r in response.data['results']] + assert 'ccx_coach' in returned_roles + ccx_entry = next(r for r in response.data['results'] if r['role'] == 'ccx_coach') + assert ccx_entry['display_name'] == 'CCX Coach' + + def test_list_roles_unauthenticated(self): + """Unauthenticated request returns 401.""" + response = self.client.get(self.url) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_roles_no_permission(self): + """Student without instructor access gets 403.""" + self.client.force_authenticate(user=self.student) + response = self.client.get(self.url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@ddt.ddt +class CourseTeamViewTest(SharedModuleStoreTestCase): + """Tests for CourseTeamView (GET list and POST grant) endpoints.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create( + org='edX', + number='TeamX', + run='2024', + display_name='Team Test Course', + ) + cls.course_key = cls.course.id + + def setUp(self): + super().setUp() + self.client = APIClient() + self.instructor = InstructorFactory.create(course_key=self.course_key) + self.staff_user = StaffFactory.create(course_key=self.course_key) + self.student = UserFactory.create() + self.url = reverse('instructor_api_v2:course_team', kwargs={'course_id': str(self.course_key)}) + + # ---- GET tests ---- + + def test_list_staff_members(self): + """Instructors can list users with a given role.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'role': 'staff'}) + + assert response.status_code == status.HTTP_200_OK + assert response.data['course_id'] == str(self.course_key) + assert response.data['role'] == 'staff' + usernames = [m['username'] for m in response.data['results']] + assert self.staff_user.username in usernames + + def test_list_instructors(self): + """List instructor role members.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'role': 'instructor'}) + + assert response.status_code == status.HTTP_200_OK + usernames = [m['username'] for m in response.data['results']] + assert self.instructor.username in usernames + + def test_list_all_roles_when_no_role_param(self): + """GET without role param returns all team members across all roles.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['role'] is None + usernames = [m['username'] for m in response.data['results']] + assert self.instructor.username in usernames + assert self.staff_user.username in usernames + # Verify role field is present on each result + for member in response.data['results']: + assert 'role' in member + + def test_list_invalid_role(self): + """GET with invalid role returns 400.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'role': 'nonexistent'}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_list_unauthenticated(self): + """Unauthenticated request returns 401.""" + response = self.client.get(self.url, {'role': 'staff'}) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_no_permission(self): + """Student without instructor access gets 403.""" + self.client.force_authenticate(user=self.student) + response = self.client.get(self.url, {'role': 'staff'}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_list_response_fields(self): + """Verify response contains expected user fields.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'role': 'staff'}) + + assert response.status_code == status.HTTP_200_OK + for member in response.data['results']: + assert 'username' in member + assert 'email' in member + assert 'first_name' in member + assert 'last_name' in member + assert 'role' in member + assert member['role'] == 'staff' + + @ddt.data('instructor', 'staff', 'limited_staff', 'beta', 'ccx_coach', 'data_researcher') + def test_list_all_valid_roles(self, role): + """GET with any valid role returns 200.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'role': role}) + + assert response.status_code == status.HTTP_200_OK + assert response.data['role'] == role + assert 'results' in response.data + + # ---- POST tests ---- + + def test_grant_staff_role(self): + """Grant staff role to a user.""" + new_user = UserFactory.create() + self.client.force_authenticate(user=self.instructor) + response = self.client.post(self.url, { + 'identifier': new_user.username, + 'role': 'staff', + }, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['success'] + assert response.data['action'] == 'allow' + assert CourseStaffRole(self.course_key).has_user(new_user) + + def test_grant_role_auto_enrolls(self): + """Granting a role also enrolls the user if not already enrolled.""" + new_user = UserFactory.create() + self.client.force_authenticate(user=self.instructor) + self.client.post(self.url, { + 'identifier': new_user.username, + 'role': 'staff', + }, format='json') + + assert CourseEnrollment.is_enrolled(new_user, self.course_key) + + def test_grant_role_user_not_found(self): + """Granting a role to non-existent user returns 404.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.post(self.url, { + 'identifier': 'nonexistent_user_12345', + 'role': 'staff', + }, format='json') + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_grant_role_invalid_role(self): + """Granting an invalid role returns 400.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.post(self.url, { + 'identifier': self.student.username, + 'role': 'nonexistent', + }, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_grant_role_inactive_user(self): + """Granting a role to an inactive user returns 400.""" + inactive_user = UserFactory.create(is_active=False) + self.client.force_authenticate(user=self.instructor) + response = self.client.post(self.url, { + 'identifier': inactive_user.username, + 'role': 'staff', + }, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # ---- Search filter tests ---- + + def test_list_search_filters_by_username(self): + """Search query param filters results by username substring (case-insensitive).""" + target = UserFactory.create(username='alicelookup', email='alice@example.com') + CourseStaffRole(self.course_key).add_users(target) + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'search': 'ALICELOOK'}) + + assert response.status_code == status.HTTP_200_OK + assert response.data['search'] == 'ALICELOOK' + usernames = [m['username'] for m in response.data['results']] + assert target.username in usernames + assert self.staff_user.username not in usernames + + def test_list_search_filters_by_email(self): + """Search query param filters results by email substring.""" + target = UserFactory.create(email='needle@example.com') + CourseStaffRole(self.course_key).add_users(target) + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'search': 'needle'}) + + assert response.status_code == status.HTTP_200_OK + emails = [m['email'] for m in response.data['results']] + assert target.email in emails + + def test_list_search_no_match_returns_empty(self): + """Search that matches nothing returns an empty results list.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'search': 'zzz_no_match_zzz'}) + + assert response.status_code == status.HTTP_200_OK + assert response.data['results'] == [] + + def test_list_search_null_when_omitted(self): + """Search field is null in response when no search param is provided.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['search'] is None + + # ---- Forum role tests ---- + + def test_grant_forum_role(self): + """POST with a forum role grants the role via the forum role system.""" + seed_permissions_roles(self.course_key) + + new_user = UserFactory.create() + self.client.force_authenticate(user=self.instructor) + response = self.client.post(self.url, { + 'identifier': new_user.username, + 'role': 'Moderator', + }, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['role'] == 'Moderator' + assert response.data['action'] == 'allow' + role = Role.objects.get(course_id=self.course_key, name='Moderator') + assert role.users.filter(pk=new_user.pk).exists() + + def test_list_forum_role(self): + """GET with a forum role query lists forum role holders.""" + seed_permissions_roles(self.course_key) + + target = UserFactory.create() + role = Role.objects.get(course_id=self.course_key, name='Community TA') + role.users.add(target) + + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self.url, {'role': 'Community TA'}) + + assert response.status_code == status.HTTP_200_OK + assert response.data['role'] == 'Community TA' + usernames = [m['username'] for m in response.data['results']] + assert target.username in usernames + for member in response.data['results']: + assert member['role'] == 'Community TA' + +class InstructorPermissionInvalidKeyTest(SimpleTestCase): + """InstructorPermission must translate InvalidKeyError into Http404.""" + + def test_invalid_course_key_raises_http404(self): + """A malformed course_id on the view kwargs raises Http404, not 500.""" + permission = InstructorPermission() + request = APIRequestFactory().get('/irrelevant') + view = Mock(kwargs={'course_id': 'this-is-not-a-course-key'}) + + try: + permission.has_permission(request, view) + except Http404: + return + raise AssertionError('Expected Http404 for invalid course key') + + +class CourseTeamMemberViewTest(SharedModuleStoreTestCase): + """Tests for CourseTeamMemberView (DELETE revoke) endpoint.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create( + org='edX', + number='TeamX', + run='2024_revoke', + display_name='Team Revoke Test Course', + ) + cls.course_key = cls.course.id + + def setUp(self): + super().setUp() + self.client = APIClient() + self.instructor = InstructorFactory.create(course_key=self.course_key) + self.staff_user = StaffFactory.create(course_key=self.course_key) + self.student = UserFactory.create() + + def _get_url(self, identifier, role=None): + """Build URL for course team member endpoint, optionally with role query param.""" + url = reverse( + 'instructor_api_v2:course_team_member', + kwargs={'course_id': str(self.course_key), 'identifier': identifier} + ) + if role: + url = f'{url}?role={role}' + return url + + def test_revoke_staff_role(self): + """Revoke staff role from a user.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.delete(self._get_url(self.staff_user.username, role='staff')) + + assert response.status_code == status.HTTP_200_OK + assert response.data['success'] + assert response.data['action'] == 'revoke' + assert not CourseStaffRole(self.course_key).has_user(self.staff_user) + + def test_revoke_self_instructor_blocked(self): + """Instructors cannot revoke their own instructor access.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.delete( + self._get_url(self.instructor.username, role='instructor') + ) + + assert response.status_code == status.HTTP_409_CONFLICT + + def test_revoke_missing_role_param(self): + """DELETE without role query param returns 400.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.delete(self._get_url(self.staff_user.username)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_revoke_user_not_found(self): + """Revoking from non-existent user returns 404.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.delete( + self._get_url('nonexistent_user_12345', role='staff') + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_revoke_unauthenticated(self): + """Unauthenticated request returns 401.""" + response = self.client.delete(self._get_url(self.staff_user.username, role='staff')) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_revoke_no_permission(self): + """Student without instructor access gets 403.""" + self.client.force_authenticate(user=self.student) + response = self.client.delete(self._get_url(self.staff_user.username, role='staff')) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_revoke_forum_role(self): + """DELETE with a forum role revokes the role via the forum role system.""" + seed_permissions_roles(self.course_key) + target = UserFactory.create() + role = Role.objects.get(course_id=self.course_key, name='Moderator') + role.users.add(target) + + self.client.force_authenticate(user=self.instructor) + response = self.client.delete(self._get_url(target.username, role='Moderator')) + + assert response.status_code == status.HTTP_200_OK + assert response.data['role'] == 'Moderator' + assert response.data['action'] == 'revoke' + assert not role.users.filter(pk=target.pk).exists() + + +class BulkBetaTesterViewTest(SharedModuleStoreTestCase): + """Tests for BulkBetaTesterView (POST add, DELETE remove) endpoints.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create( + org='edX', + number='BetaX', + run='2024', + display_name='Beta Test Course', + ) + cls.course_key = cls.course.id + + def setUp(self): + super().setUp() + self.client = APIClient() + self.instructor = InstructorFactory.create(course_key=self.course_key) + self.beta_user1 = UserFactory.create() + self.beta_user2 = UserFactory.create() + self.student = UserFactory.create() + self.url = reverse('instructor_api_v2:bulk_beta_testers', kwargs={'course_id': str(self.course_key)}) + + def test_bulk_add_beta_testers(self): + """Add multiple users as beta testers.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.post(self.url, { + 'identifiers': [self.beta_user1.username, self.beta_user2.username], + }, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.data['action'] == 'add' + assert len(response.data['results']) == 2 + for result in response.data['results']: + assert not result['error'] + assert CourseBetaTesterRole(self.course_key).has_user(self.beta_user1) + assert CourseBetaTesterRole(self.course_key).has_user(self.beta_user2) + + def test_bulk_add_with_auto_enroll(self): + """Adding beta testers with auto_enroll also enrolls them.""" + self.client.force_authenticate(user=self.instructor) + self.client.post(self.url, { + 'identifiers': [self.beta_user1.username], + 'auto_enroll': True, + }, format='json') + + assert CourseEnrollment.is_enrolled(self.beta_user1, self.course_key) + + def test_bulk_add_nonexistent_user(self): + """Non-existent users are reported as errors, not a 500.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.post(self.url, { + 'identifiers': [self.beta_user1.username, 'nonexistent_user_12345'], + }, format='json') + + assert response.status_code == status.HTTP_200_OK + results_by_id = {r['identifier']: r for r in response.data['results']} + assert not results_by_id[self.beta_user1.username]['error'] + assert results_by_id['nonexistent_user_12345']['error'] + assert results_by_id['nonexistent_user_12345']['user_does_not_exist'] + + def test_bulk_remove_beta_testers(self): + """Remove beta tester role from multiple users.""" + CourseBetaTesterRole(self.course_key).add_users(self.beta_user1) + CourseBetaTesterRole(self.course_key).add_users(self.beta_user2) + + self.client.force_authenticate(user=self.instructor) + response = self.client.delete(self.url, { + 'identifiers': [self.beta_user1.username, self.beta_user2.username], + }, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.data['action'] == 'remove' + assert not CourseBetaTesterRole(self.course_key).has_user(self.beta_user1) + assert not CourseBetaTesterRole(self.course_key).has_user(self.beta_user2) + + def test_bulk_empty_identifiers(self): + """Empty identifiers list returns 400.""" + self.client.force_authenticate(user=self.instructor) + response = self.client.post(self.url, { + 'identifiers': [], + }, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_bulk_unauthenticated(self): + """Unauthenticated request returns 401.""" + response = self.client.post(self.url, { + 'identifiers': [self.beta_user1.username], + }, format='json') + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_bulk_no_permission(self): + """Student without beta test permission gets 403.""" + self.client.force_authenticate(user=self.student) + response = self.client.post(self.url, { + 'identifiers': [self.beta_user1.username], + }, format='json') + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index e18ee69884cf..977188f8e48a 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -111,6 +111,28 @@ api_v2.GradingConfigView.as_view(), name='grading_config' ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/team/roles$', + api_v2.CourseTeamRolesView.as_view(), + name='course_team_roles' + ), + # IMPORTANT: beta-testers must be registered before the {identifier} pattern + # because (?P[^/]+) would also match the literal "beta-testers". + re_path( + rf'^courses/{COURSE_ID_PATTERN}/team/beta-testers$', + api_v2.BulkBetaTesterView.as_view(), + name='bulk_beta_testers' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/team/(?P[^/]+)$', + api_v2.CourseTeamMemberView.as_view(), + name='course_team_member' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/team$', + api_v2.CourseTeamView.as_view(), + name='course_team' + ), ] urlpatterns = [ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 3b6422faad88..38db5b7e7a83 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -36,6 +36,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from common.djangoapps.student.api import is_user_enrolled_in_course from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.models.user import get_user_by_username_or_email from common.djangoapps.student.roles import CourseBetaTesterRole @@ -52,7 +53,19 @@ from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.courseware.tabs import get_course_tab_list from lms.djangoapps.instructor import permissions +from lms.djangoapps.instructor.access import ( + FORUM_ROLES, + ROLE_DISPLAY_NAMES, + ROLES, + allow_access, + is_forum_role, + list_forum_members, + list_with_level, + revoke_access, + update_forum_role, +) from lms.djangoapps.instructor.constants import ReportType +from lms.djangoapps.instructor.enrollment import get_email_params, send_beta_role_email from lms.djangoapps.instructor.ora import get_open_response_assessment_list, get_ora_summary from lms.djangoapps.instructor.views.api import _display_unit, get_student_from_identifier from lms.djangoapps.instructor.views.instructor_task_helpers import extract_task_features @@ -73,9 +86,12 @@ from .filters_v2 import CourseEnrollmentFilter from .serializers_v2 import ( BlockDueDateSerializerV2, + BulkBetaTesterSerializer, CertificateGenerationHistorySerializer, CourseEnrollmentSerializerV2, CourseInformationSerializerV2, + CourseTeamModifySerializer, + CourseTeamRevokeSerializer, GradingConfigSerializer, InstructorTaskListSerializer, IssuedCertificateSerializer, @@ -89,6 +105,7 @@ ) from .tools import find_unit, get_units_with_due_date, keep_field_private, set_due_date_extension, title_or_url +User = get_user_model() log = logging.getLogger(__name__) @@ -2030,3 +2047,443 @@ def get(self, request, course_id): } serializer = GradingConfigSerializer(config_data) return Response(serializer.data, status=status.HTTP_200_OK) + + +class CourseTeamRolesView(DeveloperErrorViewMixin, APIView): + """ + List the available course team roles for a specific course. + + The returned roles are filtered based on course configuration. + For example, the ``ccx_coach`` role is only included when the + ``CUSTOM_COURSES_EDX`` feature flag is enabled **and** the course + has CCX enabled (``course.enable_ccx``). + + **GET Example Request** + + GET /api/instructor/v2/courses/{course_id}/team/roles + + **GET Response Values** + + { + "course_id": "course-v1:edX+DemoX+Demo_Course", + "results": [ + {"role": "beta", "display_name": "Beta Tester"}, + {"role": "data_researcher", "display_name": "Data Researcher"}, + {"role": "instructor", "display_name": "Instructor"}, + {"role": "limited_staff", "display_name": "Limited Staff"}, + {"role": "staff", "display_name": "Staff"} + ] + } + + **Returns** + + * 200: OK + * 401: User is not authenticated + * 403: User lacks instructor permissions + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EDIT_COURSE_ACCESS + + def get(self, request, course_id): + """Return the list of available course team roles for this course.""" + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + + roles = set(ROLES.keys()) | set(FORUM_ROLES) + + ccx_enabled = settings.FEATURES.get('CUSTOM_COURSES_EDX', False) and course.enable_ccx + if not ccx_enabled: + roles.discard('ccx_coach') + + results = [ + {'role': rolename, 'display_name': str(ROLE_DISPLAY_NAMES[rolename])} + for rolename in sorted(roles) + ] + + return Response({ + 'course_id': str(course_key), + 'results': results, + }, status=status.HTTP_200_OK) + + +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class CourseTeamView(DeveloperErrorViewMixin, APIView): + """ + List course team members, or grant a role to a user. + + **GET Example Requests** + + GET /api/instructor/v2/courses/{course_id}/team + GET /api/instructor/v2/courses/{course_id}/team?role=staff + + **GET Response Values** + + When ``role`` is omitted, returns all team members across all roles: + + { + "course_id": "course-v1:edX+DemoX+Demo_Course", + "role": null, + "results": [ + { + "username": "instructor1", + "email": "instructor1@example.com", + "first_name": "Alice", + "last_name": "Smith", + "role": "instructor" + }, + { + "username": "staff_user1", + "email": "staff1@example.com", + "first_name": "Bob", + "last_name": "Jones", + "role": "staff" + } + ] + } + + When ``role`` is specified, returns only members with that role: + + { + "course_id": "course-v1:edX+DemoX+Demo_Course", + "role": "staff", + "results": [ + { + "username": "staff_user1", + "email": "staff1@example.com", + "first_name": "Bob", + "last_name": "Jones", + "role": "staff" + } + ] + } + + **POST Example Request** + + POST /api/instructor/v2/courses/{course_id}/team + { + "identifier": "jane_doe", + "role": "staff" + } + + **POST Response Values** + + { + "identifier": "jane_doe", + "role": "staff", + "action": "allow", + "success": true + } + + **Returns** + + * 200: OK (GET) + * 201: Created (POST - role granted) + * 400: Invalid parameters + * 401: User is not authenticated + * 403: User lacks instructor permissions + * 404: Course or user not found + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EDIT_COURSE_ACCESS + + def get(self, request, course_id): + """ + List course team members, optionally filtered by role and search text. + + If no role is specified, returns members across all course and forum + roles. The optional ``search`` query param matches (case-insensitive, + substring) against username, email, first name, and last name. + """ + course_key = CourseKey.from_string(course_id) + role = request.query_params.get('role') + search = (request.query_params.get('search') or '').strip() + + valid_roles = set(ROLES.keys()) | set(FORUM_ROLES) + if role and role not in valid_roles: + return Response( + {'error': _("Invalid role '%(role)s'. Must be one of: %(valid)s") % { + 'role': role, + 'valid': ', '.join(sorted(valid_roles)), + }}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Verify course exists + get_course_by_id(course_key) + + if role: + roles_to_query = [role] + else: + roles_to_query = list(ROLES.keys()) + list(FORUM_ROLES) + + results = [] + for rolename in roles_to_query: + if is_forum_role(rolename): + users = list_forum_members(course_key, rolename) + else: + users = list_with_level(course_key, rolename) + for user in users: + results.append({ + 'username': user.username, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'role': rolename, + }) + + if search: + needle = search.lower() + results = [ + r for r in results + if needle in r['username'].lower() + or needle in r['email'].lower() + or needle in (r['first_name'] or '').lower() + or needle in (r['last_name'] or '').lower() + ] + + return Response({ + 'course_id': str(course_key), + 'role': role, + 'search': search or None, + 'results': results, + }, status=status.HTTP_200_OK) + + def post(self, request, course_id): + """Grant a course role to a user.""" + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + + serializer = CourseTeamModifySerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + identifier = serializer.validated_data['identifier'] + rolename = serializer.validated_data['role'] + + try: + user = get_student_from_identifier(identifier) + except User.DoesNotExist: + return Response( + {'error': _("User '%(identifier)s' not found.") % {'identifier': identifier}}, + status=status.HTTP_404_NOT_FOUND + ) + + if not user.is_active: + return Response( + {'error': _("User '%(identifier)s' is inactive.") % {'identifier': identifier}}, + status=status.HTTP_400_BAD_REQUEST + ) + + if is_forum_role(rolename): + update_forum_role(course_key, user, rolename, 'allow') + else: + allow_access(course, user, rolename) + if not is_user_enrolled_in_course(user, course_key): + CourseEnrollment.enroll(user, course_key) + + return Response({ + 'identifier': user.username, + 'role': rolename, + 'action': 'allow', + 'success': True, + }, status=status.HTTP_201_CREATED) + + +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class CourseTeamMemberView(DeveloperErrorViewMixin, APIView): + """ + Revoke a course role from a user. + + **DELETE Example Request** + + DELETE /api/instructor/v2/courses/{course_id}/team/jane_doe?role=staff + + **DELETE Response Values** + + { + "identifier": "jane_doe", + "role": "staff", + "action": "revoke", + "success": true + } + + **Returns** + + * 200: Role revoked successfully + * 400: Invalid parameters + * 401: User is not authenticated + * 403: User lacks instructor permissions + * 404: Course or user not found + * 409: Cannot remove own instructor access + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EDIT_COURSE_ACCESS + + def delete(self, request, course_id, identifier): + """Revoke a course role from a user.""" + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + + revoke_serializer = CourseTeamRevokeSerializer(data=request.query_params) + if not revoke_serializer.is_valid(): + return Response(revoke_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + rolename = revoke_serializer.validated_data['role'] + + try: + user = get_student_from_identifier(identifier) + except User.DoesNotExist: + return Response( + {'error': _("User '%(identifier)s' not found.") % {'identifier': identifier}}, + status=status.HTTP_404_NOT_FOUND + ) + + if not user.is_active: + return Response( + {'error': _("User '%(identifier)s' is inactive.") % {'identifier': identifier}}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Instructors cannot remove their own instructor access + if rolename == 'instructor' and user == request.user: + return Response( + {'error': _('Instructors cannot remove their own instructor access.')}, + status=status.HTTP_409_CONFLICT + ) + + if is_forum_role(rolename): + update_forum_role(course_key, user, rolename, 'revoke') + else: + revoke_access(course, user, rolename) + + return Response({ + 'identifier': user.username, + 'role': rolename, + 'action': 'revoke', + 'success': True, + }, status=status.HTTP_200_OK) + + +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class BulkBetaTesterView(DeveloperErrorViewMixin, APIView): + """ + Bulk add or remove beta testers for a course. + + **POST Example Request** (add beta testers) + + POST /api/instructor/v2/courses/{course_id}/team/beta-testers + { + "identifiers": ["beta_user1", "beta@example.com"], + "auto_enroll": true, + "email_students": false + } + + **DELETE Example Request** (remove beta testers) + + DELETE /api/instructor/v2/courses/{course_id}/team/beta-testers + { + "identifiers": ["beta_user1", "beta@example.com"], + "email_students": false + } + + **POST Response Values** + + { + "action": "add", + "results": [ + { + "identifier": "beta_user1", + "error": false, + "user_does_not_exist": false, + "is_active": true + } + ] + } + + **DELETE Response Values** + + { + "action": "remove", + "results": [ + { + "identifier": "beta_user1", + "error": false, + "user_does_not_exist": false, + "is_active": true + } + ] + } + + **Returns** + + * 200: Operation completed + * 400: Invalid parameters + * 401: User is not authenticated + * 403: User lacks beta test permissions + * 404: Course not found + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_BETATEST + + def _process_beta_testers(self, request, course_id, action): + """Shared logic for adding/removing beta testers.""" + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + + serializer = BulkBetaTesterSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + identifiers = serializer.validated_data['identifiers'] + email_students = serializer.validated_data['email_students'] + auto_enroll = serializer.validated_data.get('auto_enroll', False) + + email_params = {} + if email_students: + email_params = get_email_params(course, auto_enroll=auto_enroll, secure=request.is_secure()) + + results = [] + for identifier in identifiers: + error = False + user_does_not_exist = False + user_active = None + try: + user = get_student_from_identifier(identifier) + user_active = user.is_active + + if action == 'add': + allow_access(course, user, 'beta') + else: + revoke_access(course, user, 'beta') + except User.DoesNotExist: + error = True + user_does_not_exist = True + except Exception: # pylint: disable=broad-except + log.exception("Error while %sing beta tester %s", action, identifier) + error = True + else: + if email_students: + send_beta_role_email(action, user, email_params) + if auto_enroll and action == 'add': + if not is_user_enrolled_in_course(user, course_key): + CourseEnrollment.enroll(user, course_key) + finally: + results.append({ + 'identifier': identifier, + 'error': error, + 'user_does_not_exist': user_does_not_exist, + 'is_active': user_active, + }) + + return Response({ + 'action': action, + 'results': results, + }, status=status.HTTP_200_OK) + + def post(self, request, course_id): + """Bulk add beta testers.""" + return self._process_beta_testers(request, course_id, 'add') + + def delete(self, request, course_id): + """Bulk remove beta testers.""" + return self._process_beta_testers(request, course_id, 'remove') diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index c7d0888d7c4b..1acd92efe197 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -28,6 +28,7 @@ from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access from lms.djangoapps.grades.api import is_writable_gradebook_enabled from lms.djangoapps.instructor import permissions +from lms.djangoapps.instructor.access import FORUM_ROLES, ROLES from lms.djangoapps.instructor.views.instructor_dashboard import get_analytics_dashboard_message from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_ADMINISTRATOR from xmodule.modulestore.django import modulestore @@ -861,3 +862,40 @@ class TaskStatusSerializer(serializers.Serializer): updated_at = serializers.DateTimeField( help_text="Last update timestamp" ) + + +class CourseTeamModifySerializer(serializers.Serializer): + """Input serializer for granting a course team role.""" + identifier = serializers.CharField( + max_length=255, + help_text="Username or email of the user" + ) + role = serializers.ChoiceField( + choices=list(ROLES.keys()) + list(FORUM_ROLES), + help_text="The role to grant (course access role or forum role)" + ) + + +class CourseTeamRevokeSerializer(serializers.Serializer): + """Input serializer for revoking a course team role (query param).""" + role = serializers.ChoiceField( + choices=list(ROLES.keys()) + list(FORUM_ROLES), + help_text="The role to revoke (course access role or forum role)" + ) + + +class BulkBetaTesterSerializer(serializers.Serializer): + """Input serializer for bulk beta tester operations.""" + identifiers = serializers.ListField( + child=serializers.CharField(max_length=255), + min_length=1, + help_text="List of usernames or email addresses" + ) + auto_enroll = serializers.BooleanField( + default=False, + help_text="Automatically enroll users who are not already enrolled" + ) + email_students = serializers.BooleanField( + default=False, + help_text="Send notification email to users" + )