From 1edccfaeaae679ec4c63d9ee9934e6e6a16cf6a8 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Tue, 20 Jan 2026 16:15:03 -0700 Subject: [PATCH 1/2] feat: Add Content Groups API v2 endpoints for Instructor Dashboard - Add GET /api/instructor/v2/courses/{course_id}/group_configurations - Add GET /api/instructor/v2/courses/{course_id}/group_configurations/{id} - Create shared constants module for course groups - Add serializers for content group configurations - Add unit tests for new endpoints - Add OpenAPI spec documentation Refactor content groups API from LMS to core. --- .../contentstore/course_group_config.py | 18 +- .../instructor-v2-content-groups-spec.yaml | 172 +++++++++++ .../views/test_group_configurations_v2.py | 214 ++++++++++++++ lms/djangoapps/instructor/views/api_urls.py | 11 + lms/djangoapps/instructor/views/api_v2.py | 273 ++++++++++-------- .../instructor/views/serializers_v2.py | 98 +++++++ .../djangoapps/course_groups/constants.py | 13 + .../course_groups/rest_api/__init__.py | 0 .../course_groups/rest_api/serializers.py | 45 +++ .../course_groups/rest_api/views.py | 160 ++++++++++ 10 files changed, 881 insertions(+), 123 deletions(-) create mode 100644 lms/djangoapps/instructor/docs/references/instructor-v2-content-groups-spec.yaml create mode 100644 lms/djangoapps/instructor/tests/views/test_group_configurations_v2.py create mode 100644 openedx/core/djangoapps/course_groups/constants.py create mode 100644 openedx/core/djangoapps/course_groups/rest_api/__init__.py create mode 100644 openedx/core/djangoapps/course_groups/rest_api/serializers.py create mode 100644 openedx/core/djangoapps/course_groups/rest_api/views.py diff --git a/cms/djangoapps/contentstore/course_group_config.py b/cms/djangoapps/contentstore/course_group_config.py index 1c1b3fe624bf..a6babd0a0c2f 100644 --- a/cms/djangoapps/contentstore/course_group_config.py +++ b/cms/djangoapps/contentstore/course_group_config.py @@ -12,6 +12,14 @@ from cms.djangoapps.contentstore.utils import reverse_usage_url from common.djangoapps.util.db import MYSQL_MAX_INT, generate_int_id from lms.lib.utils import get_parent_unit +# Re-exported for backward compatibility - other modules import these from here +from openedx.core.djangoapps.course_groups.constants import ( # pylint: disable=unused-import + COHORT_SCHEME, + CONTENT_GROUP_CONFIGURATION_DESCRIPTION, + CONTENT_GROUP_CONFIGURATION_NAME, + ENROLLMENT_SCHEME, + RANDOM_SCHEME, +) from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition from xmodule.partitions.partitions import MINIMUM_UNUSED_PARTITION_ID, ReadOnlyUserPartitionError, UserPartition # lint-amnesty, pylint: disable=wrong-import-order from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order @@ -19,16 +27,6 @@ MINIMUM_GROUP_ID = MINIMUM_UNUSED_PARTITION_ID -RANDOM_SCHEME = "random" -COHORT_SCHEME = "cohort" -ENROLLMENT_SCHEME = "enrollment_track" - -CONTENT_GROUP_CONFIGURATION_DESCRIPTION = _( - 'The groups in this configuration can be mapped to cohorts in the Instructor Dashboard.' -) - -CONTENT_GROUP_CONFIGURATION_NAME = _('Content Groups') - log = logging.getLogger(__name__) diff --git a/lms/djangoapps/instructor/docs/references/instructor-v2-content-groups-spec.yaml b/lms/djangoapps/instructor/docs/references/instructor-v2-content-groups-spec.yaml new file mode 100644 index 000000000000..0b6cdfdeff54 --- /dev/null +++ b/lms/djangoapps/instructor/docs/references/instructor-v2-content-groups-spec.yaml @@ -0,0 +1,172 @@ +swagger: '2.0' +info: + title: Instructor Dashboard Content Groups API v2 + version: 2.0.0 + description: | + REST API for managing content group configurations. + + Content groups allow course authors to restrict access to specific + course content based on cohort membership. + +host: courses.example.com +basePath: / +schemes: + - https + +securityDefinitions: + JWTAuth: + type: apiKey + in: header + name: Authorization + description: JWT token authentication. + +security: + - JWTAuth: [] + +tags: + - name: Content Groups + description: Content group configuration management + +parameters: + CourseId: + name: course_id + in: path + required: true + type: string + description: The course key (e.g., course-v1:org+course+run) + ConfigurationId: + name: configuration_id + in: path + required: true + type: integer + description: The ID of the content group configuration + +paths: + /api/instructor/v2/courses/{course_id}/group_configurations: + get: + tags: + - Content Groups + summary: List content group configurations + description: | + Returns all content group configurations (scheme='cohort') for a course. + If no content group exists, an empty one is automatically created. + operationId: listGroupConfigurations + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseId' + responses: + 200: + description: Content groups retrieved successfully + schema: + $ref: '#/definitions/ContentGroupsListResponse' + 400: + description: Invalid course key + 401: + description: Authentication required + 403: + description: User lacks instructor permission + 404: + description: Course not found + + /api/instructor/v2/courses/{course_id}/group_configurations/{configuration_id}: + get: + tags: + - Content Groups + summary: Get content group configuration details + description: | + Retrieve a specific content group configuration by ID. + Only returns configurations with scheme='cohort'. + operationId: getGroupConfiguration + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseId' + - $ref: '#/parameters/ConfigurationId' + responses: + 200: + description: Configuration retrieved successfully + schema: + $ref: '#/definitions/ContentGroupConfiguration' + 400: + description: Invalid course key + 401: + description: Authentication required + 403: + description: User lacks instructor permission + 404: + description: Configuration not found + +definitions: + Group: + type: object + properties: + id: + type: integer + description: Unique identifier for the group + name: + type: string + description: Display name of the group + version: + type: integer + description: Version number of the group + usage: + type: array + items: + type: object + description: List of content blocks using this group + + ContentGroupConfiguration: + type: object + properties: + id: + type: integer + description: Unique identifier for the configuration + name: + type: string + description: Display name (typically "Content Groups") + scheme: + type: string + enum: [cohort] + description: Partition scheme type + description: + type: string + description: Human-readable description + parameters: + type: object + description: Additional configuration parameters + groups: + type: array + items: + $ref: '#/definitions/Group' + description: List of groups in this configuration + active: + type: boolean + description: Whether this configuration is active + version: + type: integer + description: Version number of the configuration + read_only: + type: boolean + description: Whether this configuration is system-managed + + ContentGroupsListResponse: + type: object + properties: + all_group_configurations: + type: array + items: + $ref: '#/definitions/ContentGroupConfiguration' + description: List of content group configurations + should_show_enrollment_track: + type: boolean + description: Whether enrollment track groups should be displayed + should_show_experiment_groups: + type: boolean + description: Whether experiment groups should be displayed + group_configuration_url: + type: string + description: Base URL for accessing individual configurations + course_outline_url: + type: string + description: URL to the course outline diff --git a/lms/djangoapps/instructor/tests/views/test_group_configurations_v2.py b/lms/djangoapps/instructor/tests/views/test_group_configurations_v2.py new file mode 100644 index 000000000000..190723b205d8 --- /dev/null +++ b/lms/djangoapps/instructor/tests/views/test_group_configurations_v2.py @@ -0,0 +1,214 @@ +""" +Tests for Content Groups (Group Configurations) V2 API in Instructor Dashboard. +""" +from unittest.mock import patch + +from rest_framework import status +from rest_framework.test import APIClient + +from xmodule.partitions.partitions import Group, UserPartition +from common.djangoapps.student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from openedx.core.djangoapps.course_groups.constants import COHORT_SCHEME + + +class GroupConfigurationsListViewTestCase(ModuleStoreTestCase): + """ + Tests for GET /api/instructor/v2/courses/{course_id}/group_configurations + """ + + def setUp(self): + super().setUp() + self.api_client = APIClient() + self.user = UserFactory(is_staff=False) + self.course = CourseFactory.create() + self.api_client.force_authenticate(user=self.user) + + def _get_url(self, course_id=None): + """Helper to get the list URL""" + course_id = course_id or str(self.course.id) + return f'/api/instructor/v2/courses/{course_id}/group_configurations' + + @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') + def test_list_content_groups_returns_json(self, mock_perm): + """Verify endpoint returns JSON with correct structure""" + mock_perm.return_value = True + + # Create content groups + self.course.user_partitions = [ + UserPartition( + id=50, + name='Content Groups', + description='Test description', + groups=[ + Group(id=1, name='Content Group A'), + Group(id=2, name='Content Group B'), + ], + scheme_id=COHORT_SCHEME + ) + ] + self.update_course(self.course, self.user.id) + + # Get endpoint + response = self.api_client.get(self._get_url()) + + # Verify response + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response['Content-Type'], 'application/json') + + data = response.json() + self.assertIn('all_group_configurations', data) + self.assertIn('should_show_enrollment_track', data) + self.assertIn('should_show_experiment_groups', data) + + # Verify content groups + configs = data['all_group_configurations'] + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0]['scheme'], COHORT_SCHEME) + self.assertEqual(len(configs[0]['groups']), 2) + + @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') + def test_list_content_groups_filters_non_cohort_partitions(self, mock_perm): + """Verify only cohort-scheme partitions are returned""" + mock_perm.return_value = True + + # Create mixed partitions + self.course.user_partitions = [ + UserPartition( + id=50, + name='Content Groups', + description='Cohort-based content groups', + groups=[Group(id=1, name='Group A')], + scheme_id=COHORT_SCHEME + ), + UserPartition( + id=51, + name='Experiment Groups', + description='Random experiment groups', + groups=[Group(id=1, name='Group B')], + scheme_id='random' # Not cohort scheme + ), + ] + self.update_course(self.course, self.user.id) + + response = self.api_client.get(self._get_url()) + + data = response.json() + configs = data['all_group_configurations'] + + # Should only return cohort-scheme partition + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0]['id'], 50) + self.assertEqual(configs[0]['scheme'], COHORT_SCHEME) + + @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') + def test_list_auto_creates_empty_content_group_if_none_exists(self, mock_perm): + """Verify empty content group is auto-created when none exists""" + mock_perm.return_value = True + + # Don't add any partitions + response = self.api_client.get(self._get_url()) + + data = response.json() + configs = data['all_group_configurations'] + + # Should have auto-created empty content group + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0]['scheme'], COHORT_SCHEME) + self.assertEqual(len(configs[0]['groups']), 0) + + def test_list_requires_authentication(self): + """Verify endpoint requires authentication""" + client = APIClient() # Unauthenticated + response = client.get(self._get_url()) + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + + @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') + def test_list_invalid_course_key_returns_400(self, mock_perm): + """Verify invalid course key returns 400""" + mock_perm.return_value = True + + # Use a course key that matches URL pattern but is invalid when parsed + response = self.api_client.get('/api/instructor/v2/courses/course-v1:org/course/run/group_configurations') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class GroupConfigurationDetailViewTestCase(ModuleStoreTestCase): + """ + Tests for GET /api/instructor/v2/courses/{course_id}/group_configurations/{id} + """ + + def setUp(self): + super().setUp() + self.api_client = APIClient() + self.user = UserFactory(is_staff=False) + self.course = CourseFactory.create() + self.api_client.force_authenticate(user=self.user) + + # Create a test configuration + self.course.user_partitions = [ + UserPartition( + id=50, + name='Test Content Groups', + description='Test', + groups=[ + Group(id=1, name='Group A'), + Group(id=2, name='Group B'), + ], + scheme_id=COHORT_SCHEME + ) + ] + self.update_course(self.course, self.user.id) + + def _get_url(self, configuration_id=50): + """Helper to get detail URL""" + return f'/api/instructor/v2/courses/{self.course.id}/group_configurations/{configuration_id}' + + @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') + def test_get_configuration_details(self, mock_perm): + """Verify GET returns full configuration details""" + mock_perm.return_value = True + + response = self.api_client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + self.assertEqual(data['id'], 50) + self.assertEqual(data['name'], 'Test Content Groups') + self.assertEqual(data['scheme'], COHORT_SCHEME) + self.assertEqual(len(data['groups']), 2) + + +class ContentGroupsPermissionsTestCase(ModuleStoreTestCase): + """ + Tests for permission checking + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.staff_user = UserFactory(is_staff=False) + self.regular_user = UserFactory() + + def _get_url(self): + """Helper to get list URL""" + return f'/api/instructor/v2/courses/{self.course.id}/group_configurations' + + @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') + def test_staff_user_can_access(self, mock_perm): + """Verify staff users can access the endpoint""" + mock_perm.return_value = True + + client = APIClient() + client.force_authenticate(user=self.staff_user) + + response = client.get(self._get_url()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_unauthenticated_user_denied(self): + """Verify unauthenticated users are denied""" + client = APIClient() + response = client.get(self._get_url()) + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 5a10a826fcc6..8e7a205da23b 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -7,6 +7,7 @@ from lms.djangoapps.instructor.views import api, gradebook_api, api_v2 from openedx.core.constants import COURSE_ID_PATTERN +from openedx.core.djangoapps.course_groups.rest_api import views as course_groups_views # These endpoints are exposing existing views in a way that can be used by MFEs # or other API clients. They are currently versioned at `v1` since they have @@ -41,6 +42,16 @@ api_v2.GradedSubsectionsView.as_view(), name='graded_subsections' ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/group_configurations$', + course_groups_views.GroupConfigurationsListView.as_view(), + name='group_configurations_list' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/group_configurations/(?P\d+)$', + course_groups_views.GroupConfigurationDetailView.as_view(), + name='group_configurations_detail' + ), re_path( rf'^courses/{COURSE_ID_PATTERN}/ora$', api_v2.ORAView.as_view(), diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index ff6d7c04aa5b..76923b23659c 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -8,6 +8,7 @@ import logging import edx_api_doc_tools as apidocs +from common.djangoapps.util.db import MYSQL_MAX_INT, generate_int_id from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from rest_framework import status @@ -21,19 +22,31 @@ from django.utils.html import strip_tags from django.utils.translation import gettext as _ from common.djangoapps.util.json_request import JsonResponseBadRequest +from openedx.core.djangoapps.course_groups.constants import ( + CONTENT_GROUP_CONFIGURATION_NAME, + CONTENT_GROUP_CONFIGURATION_DESCRIPTION, + COHORT_SCHEME +) +from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition +from openedx.core.djangoapps.course_groups.rest_api.serializers import ( + ContentGroupConfigurationSerializer, + ContentGroupsListResponseSerializer +) +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.partitions.partitions import MINIMUM_UNUSED_PARTITION_ID, UserPartition from lms.djangoapps.courseware.tabs import get_course_tab_list from lms.djangoapps.instructor import permissions +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 from lms.djangoapps.instructor_task import api as task_api -from lms.djangoapps.instructor.ora import get_open_response_assessment_list, get_ora_summary from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id from .serializers_v2 import ( - InstructorTaskListSerializer, - CourseInformationSerializerV2, BlockDueDateSerializerV2, + CourseInformationSerializerV2, + InstructorTaskListSerializer, ORASerializer, ORASummarySerializer, ) @@ -77,76 +90,6 @@ def get(self, request, course_id): """ Retrieve comprehensive course information including metadata, enrollment statistics, dashboard configuration, and user permissions. - - **Use Cases** - - Retrieve comprehensive course metadata including enrollment counts, dashboard configuration, - permissions, and navigation sections. - - **Example Requests** - - GET /api/instructor/v2/courses/{course_id} - - **Response Values** - - { - "course_id": "course-v1:edX+DemoX+Demo_Course", - "display_name": "Demonstration Course", - "org": "edX", - "course_number": "DemoX", - "enrollment_start": "2013-02-05T00:00:00Z", - "enrollment_end": null, - "start": "2013-02-05T05:00:00Z", - "end": "2024-12-31T23:59:59Z", - "pacing": "instructor", - "has_started": true, - "has_ended": false, - "total_enrollment": 150, - "enrollment_counts": { - "total": 150, - "audit": 100, - "verified": 40, - "honor": 10 - }, - "num_sections": 12, - "grade_cutoffs": "A is 0.9, B is 0.8, C is 0.7, D is 0.6", - "course_errors": [], - "studio_url": "https://studio.example.com/course/course-v1:edX+DemoX+2024", - "permissions": { - "admin": false, - "instructor": true, - "finance_admin": false, - "sales_admin": false, - "staff": true, - "forum_admin": true, - "data_researcher": false - }, - "tabs": [ - { - "tab_id": "courseware", - "title": "Course", - "url": "INSTRUCTOR_MICROFRONTEND_URL/courses/course-v1:edX+DemoX+2024/courseware" - }, - { - "tab_id": "progress", - "title": "Progress", - "url": "INSTRUCTOR_MICROFRONTEND_URL/courses/course-v1:edX+DemoX+2024/progress" - }, - ], - "disable_buttons": false, - "analytics_dashboard_message": "To gain insights into student enrollment and participation..." - } - - **Parameters** - - course_key: Course key for the course. - - **Returns** - - * 200: OK - Returns course metadata - * 401: Unauthorized - User is not authenticated - * 403: Forbidden - User lacks instructor permissions - * 404: Not Found - Course does not exist """ course_key = CourseKey.from_string(course_id) course = get_course_by_id(course_key) @@ -168,46 +111,6 @@ class InstructorTaskListView(DeveloperErrorViewMixin, APIView): **Use Cases** List instructor tasks for a course. - - **Example Requests** - - GET /api/instructor/v2/courses/{course_key}/instructor_tasks - GET /api/instructor/v2/courses/{course_key}/instructor_tasks?problem_location_str=block-v1:... - GET /api/instructor/v2/courses/{course_key}/instructor_tasks? - problem_location_str=block-v1:...&unique_student_identifier=student@example.com - - **Response Values** - - { - "tasks": [ - { - "task_id": "2519ff31-22d9-4a62-91e2-55495895b355", - "task_type": "grade_problems", - "task_state": "PROGRESS", - "status": "Incomplete", - "created": "2019-01-15T18:00:15.902470+00:00", - "task_input": "{}", - "task_output": null, - "duration_sec": "unknown", - "task_message": "No status information available", - "requester": "staff" - } - ] - } - - **Parameters** - - course_key: Course key for the course. - problem_location_str (optional): Filter tasks to a specific problem location. - unique_student_identifier (optional): Filter tasks to specific student (must be used with problem_location_str). - - **Returns** - - * 200: OK - Returns list of instructor tasks - * 400: Bad Request - Invalid parameters - * 401: Unauthorized - User is not authenticated - * 403: Forbidden - User lacks instructor permissions - * 404: Not Found - Course does not exist """ permission_classes = (IsAuthenticated, permissions.InstructorPermission) @@ -444,3 +347,147 @@ def get(self, request, *args, **kwargs): serializer = self.get_serializer(items) return Response(serializer.data) + + +class GroupConfigurationsListView(DeveloperErrorViewMixin, APIView): + """ + API view for listing content group configurations. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_DASHBOARD + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", + apidocs.ParameterLocation.PATH, + description="The course key (e.g., course-v1:org+course+run)", + ), + ], + responses={ + 200: "Successfully retrieved content groups", + 400: "Invalid course key", + 401: "Authentication required", + 403: "User does not have permission to access this course", + 404: "Course not found", + }, + ) + def get(self, request, course_id): + """ + List all content groups for a course. + + Returns all content group configurations (scheme='cohort') along with + context about whether to show enrollment tracks and experiment groups. + + If no content group exists, an empty content group partition is automatically created. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + {"error": f"Invalid course key: {course_id}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + course = get_course_by_id(course_key) + except ItemNotFoundError: + return Response( + {"error": f"Course not found: {course_id}"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Get or create content group partition + content_group_partition = get_cohorted_user_partition(course) + + if content_group_partition is None: + # Auto-create empty content group if none exists + used_ids = {p.id for p in course.user_partitions} + content_group_partition = UserPartition( + id=generate_int_id(MINIMUM_UNUSED_PARTITION_ID, MYSQL_MAX_INT, used_ids), + name=str(CONTENT_GROUP_CONFIGURATION_NAME), + description=str(CONTENT_GROUP_CONFIGURATION_DESCRIPTION), + groups=[], + scheme_id=COHORT_SCHEME + ) + + # Build response context + context = { + "all_group_configurations": [content_group_partition.to_json()], + "should_show_enrollment_track": False, + "should_show_experiment_groups": True, + "context_course": None, + "group_configuration_url": f"/api/instructor/v2/courses/{course_id}/group_configurations", + "course_outline_url": f"/api/contentstore/v1/courses/{course_id}", + } + + # Serialize and return + serializer = ContentGroupsListResponseSerializer(context) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class GroupConfigurationDetailView(DeveloperErrorViewMixin, APIView): + """ + API view for retrieving a specific content group configuration. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_DASHBOARD + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", + apidocs.ParameterLocation.PATH, + description="The course key", + ), + apidocs.path_parameter( + "configuration_id", + int, + description="The ID of the content group configuration", + ), + ], + responses={ + 200: "Content group configuration details", + 400: "Invalid course key", + 401: "Authentication required", + 403: "User does not have permission to access this course", + 404: "Content group configuration not found", + }, + ) + def get(self, request, course_id, configuration_id): + """ + Retrieve a specific content group configuration. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + {"error": f"Invalid course key: {course_id}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + course = get_course_by_id(course_key) + except ItemNotFoundError: + return Response( + {"error": f"Course not found: {course_id}"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Find the configuration + partition = None + for p in course.user_partitions: + if p.id == int(configuration_id) and p.scheme.name == COHORT_SCHEME: + partition = p + break + + if not partition: + return Response( + {"error": f"Content group configuration {configuration_id} not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Serialize and return + response_data = partition.to_json() + serializer = ContentGroupConfigurationSerializer(response_data) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index 414079007291..e8b528bfc482 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -448,3 +448,101 @@ class ORASummarySerializer(serializers.Serializer): waiting = serializers.IntegerField() staff = serializers.IntegerField() final_grade_received = serializers.IntegerField() + + +class GroupSerializer(serializers.Serializer): + """ + Serializer for a single group within a content group configuration. + + Groups represent cohorts that can be assigned different course content. + """ + + id = serializers.IntegerField( + help_text="Unique identifier for this group within the configuration" + ) + name = serializers.CharField( + max_length=255, + help_text="Human-readable name of the group" + ) + version = serializers.IntegerField( + help_text="Group version number (always 1 for current Group format)" + ) + usage = serializers.ListField( + child=serializers.DictField(), + required=False, + default=list, + help_text="List of course units using this group for content restriction" + ) + + +class ContentGroupConfigurationSerializer(serializers.Serializer): + """ + Serializer for a content group configuration (UserPartition with scheme='cohort'). + + Content groups enable course creators to assign different course content + to different learner cohorts. + """ + + id = serializers.IntegerField( + help_text="Unique identifier for this content group configuration" + ) + name = serializers.CharField( + max_length=255, + help_text="Human-readable name of the configuration" + ) + scheme = serializers.CharField( + help_text="Partition scheme (always 'cohort' for content groups)" + ) + description = serializers.CharField( + allow_blank=True, + help_text="Detailed description of how this group is used" + ) + parameters = serializers.DictField( + help_text="Additional partition parameters (usually empty for cohort scheme)" + ) + groups = GroupSerializer( + many=True, + help_text="List of groups (cohorts) in this configuration" + ) + active = serializers.BooleanField( + help_text="Whether this configuration is active" + ) + version = serializers.IntegerField( + help_text="Configuration version number (always 3 for current UserPartition format)" + ) + is_read_only = serializers.BooleanField( + required=False, + default=False, + help_text="Whether this configuration is read-only (system-managed)" + ) + + +class ContentGroupsListResponseSerializer(serializers.Serializer): + """ + Response serializer for listing all content groups. + + Returns content group configurations along with context about whether + to show enrollment tracks and experiment groups. + """ + + all_group_configurations = ContentGroupConfigurationSerializer( + many=True, + help_text="List of content group configurations (only scheme='cohort' partitions)" + ) + should_show_enrollment_track = serializers.BooleanField( + help_text="Whether enrollment track groups should be displayed" + ) + should_show_experiment_groups = serializers.BooleanField( + help_text="Whether experiment groups should be displayed" + ) + context_course = serializers.JSONField( + required=False, + allow_null=True, + help_text="Course context object (null in API responses)" + ) + group_configuration_url = serializers.CharField( + help_text="Base URL for accessing individual group configurations" + ) + course_outline_url = serializers.CharField( + help_text="URL to the course outline page" + ) diff --git a/openedx/core/djangoapps/course_groups/constants.py b/openedx/core/djangoapps/course_groups/constants.py new file mode 100644 index 000000000000..327d91f1009b --- /dev/null +++ b/openedx/core/djangoapps/course_groups/constants.py @@ -0,0 +1,13 @@ +""" +Constants for course groups. +""" +from django.utils.translation import gettext_lazy as _ + +COHORT_SCHEME = 'cohort' +RANDOM_SCHEME = 'random' +ENROLLMENT_SCHEME = 'enrollment_track' + +CONTENT_GROUP_CONFIGURATION_NAME = _('Content Groups') +CONTENT_GROUP_CONFIGURATION_DESCRIPTION = _( + 'Use this group configuration to control access to content.' +) diff --git a/openedx/core/djangoapps/course_groups/rest_api/__init__.py b/openedx/core/djangoapps/course_groups/rest_api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/course_groups/rest_api/serializers.py b/openedx/core/djangoapps/course_groups/rest_api/serializers.py new file mode 100644 index 000000000000..651d58a966da --- /dev/null +++ b/openedx/core/djangoapps/course_groups/rest_api/serializers.py @@ -0,0 +1,45 @@ +""" +Serializers for content group configurations REST API. +""" +from rest_framework import serializers + + +class GroupSerializer(serializers.Serializer): + """ + Serializer for a single group within a content group configuration. + """ + id = serializers.IntegerField() + name = serializers.CharField(max_length=255) + version = serializers.IntegerField() + usage = serializers.ListField( + child=serializers.DictField(), + required=False, + default=list + ) + + +class ContentGroupConfigurationSerializer(serializers.Serializer): + """ + Serializer for a content group configuration (UserPartition with scheme='cohort'). + """ + id = serializers.IntegerField() + name = serializers.CharField(max_length=255) + scheme = serializers.CharField() + description = serializers.CharField(allow_blank=True) + parameters = serializers.DictField() + groups = GroupSerializer(many=True) + active = serializers.BooleanField() + version = serializers.IntegerField() + is_read_only = serializers.BooleanField(required=False, default=False) + + +class ContentGroupsListResponseSerializer(serializers.Serializer): + """ + Response serializer for listing all content groups. + """ + all_group_configurations = ContentGroupConfigurationSerializer(many=True) + should_show_enrollment_track = serializers.BooleanField() + should_show_experiment_groups = serializers.BooleanField() + context_course = serializers.JSONField(required=False, allow_null=True) + group_configuration_url = serializers.CharField() + course_outline_url = serializers.CharField() diff --git a/openedx/core/djangoapps/course_groups/rest_api/views.py b/openedx/core/djangoapps/course_groups/rest_api/views.py new file mode 100644 index 000000000000..f49a18b09739 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/rest_api/views.py @@ -0,0 +1,160 @@ +""" +REST API views for content group configurations. +""" +import edx_api_doc_tools as apidocs +from common.djangoapps.util.db import MYSQL_MAX_INT, generate_int_id +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.partitions.partitions import MINIMUM_UNUSED_PARTITION_ID, UserPartition + +from lms.djangoapps.instructor import permissions +from openedx.core.djangoapps.course_groups.constants import ( + COHORT_SCHEME, + CONTENT_GROUP_CONFIGURATION_DESCRIPTION, + CONTENT_GROUP_CONFIGURATION_NAME, +) +from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition +from openedx.core.djangoapps.course_groups.rest_api.serializers import ( + ContentGroupConfigurationSerializer, + ContentGroupsListResponseSerializer, +) +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin +from openedx.core.lib.courses import get_course_by_id + + +class GroupConfigurationsListView(DeveloperErrorViewMixin, APIView): + """ + API view for listing content group configurations. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_DASHBOARD + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", + apidocs.ParameterLocation.PATH, + description="The course key (e.g., course-v1:org+course+run)", + ), + ], + responses={ + 200: "Successfully retrieved content groups", + 400: "Invalid course key", + 401: "Authentication required", + 403: "User does not have permission to access this course", + 404: "Course not found", + }, + ) + def get(self, request, course_id): + """ + List all content groups for a course. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + {"error": f"Invalid course key: {course_id}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + course = get_course_by_id(course_key) + except ItemNotFoundError: + return Response( + {"error": f"Course not found: {course_id}"}, + status=status.HTTP_404_NOT_FOUND + ) + + content_group_partition = get_cohorted_user_partition(course) + + if content_group_partition is None: + used_ids = {p.id for p in course.user_partitions} + content_group_partition = UserPartition( + id=generate_int_id(MINIMUM_UNUSED_PARTITION_ID, MYSQL_MAX_INT, used_ids), + name=str(CONTENT_GROUP_CONFIGURATION_NAME), + description=str(CONTENT_GROUP_CONFIGURATION_DESCRIPTION), + groups=[], + scheme_id=COHORT_SCHEME + ) + + context = { + "all_group_configurations": [content_group_partition.to_json()], + "should_show_enrollment_track": False, + "should_show_experiment_groups": True, + "context_course": None, + "group_configuration_url": f"/api/instructor/v2/courses/{course_id}/group_configurations", + "course_outline_url": f"/api/contentstore/v1/courses/{course_id}", + } + + serializer = ContentGroupsListResponseSerializer(context) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class GroupConfigurationDetailView(DeveloperErrorViewMixin, APIView): + """ + API view for retrieving a specific content group configuration. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_DASHBOARD + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", + apidocs.ParameterLocation.PATH, + description="The course key", + ), + apidocs.path_parameter( + "configuration_id", + int, + description="The ID of the content group configuration", + ), + ], + responses={ + 200: "Content group configuration details", + 400: "Invalid course key", + 401: "Authentication required", + 403: "User does not have permission to access this course", + 404: "Content group configuration not found", + }, + ) + def get(self, request, course_id, configuration_id): + """ + Retrieve a specific content group configuration. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + {"error": f"Invalid course key: {course_id}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + course = get_course_by_id(course_key) + except ItemNotFoundError: + return Response( + {"error": f"Course not found: {course_id}"}, + status=status.HTTP_404_NOT_FOUND + ) + + partition = None + for p in course.user_partitions: + if p.id == int(configuration_id) and p.scheme.name == COHORT_SCHEME: + partition = p + break + + if not partition: + return Response( + {"error": f"Content group configuration {configuration_id} not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + response_data = partition.to_json() + serializer = ContentGroupConfigurationSerializer(response_data) + return Response(serializer.data, status=status.HTTP_200_OK) From 8c6dfd3718b20318ab463a3e5b3c445876d76ceb Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Mon, 26 Jan 2026 13:54:17 -0700 Subject: [PATCH 2/2] fix: Refactor URLs and tests to core --- lms/djangoapps/instructor/views/api_urls.py | 11 ------ .../docs/content-groups-api-v2-spec.yaml | 6 ++-- .../course_groups/rest_api/tests/__init__.py | 3 ++ .../rest_api/tests/test_views.py | 34 ++++++++----------- .../djangoapps/course_groups/rest_api/urls.py | 20 +++++++++++ .../course_groups/rest_api/views.py | 20 +++++------ openedx/core/djangoapps/course_groups/urls.py | 4 ++- 7 files changed, 53 insertions(+), 45 deletions(-) rename lms/djangoapps/instructor/docs/references/instructor-v2-content-groups-spec.yaml => openedx/core/djangoapps/course_groups/rest_api/docs/content-groups-api-v2-spec.yaml (95%) create mode 100644 openedx/core/djangoapps/course_groups/rest_api/tests/__init__.py rename lms/djangoapps/instructor/tests/views/test_group_configurations_v2.py => openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py (85%) create mode 100644 openedx/core/djangoapps/course_groups/rest_api/urls.py diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 8e7a205da23b..5a10a826fcc6 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -7,7 +7,6 @@ from lms.djangoapps.instructor.views import api, gradebook_api, api_v2 from openedx.core.constants import COURSE_ID_PATTERN -from openedx.core.djangoapps.course_groups.rest_api import views as course_groups_views # These endpoints are exposing existing views in a way that can be used by MFEs # or other API clients. They are currently versioned at `v1` since they have @@ -42,16 +41,6 @@ api_v2.GradedSubsectionsView.as_view(), name='graded_subsections' ), - re_path( - rf'^courses/{COURSE_ID_PATTERN}/group_configurations$', - course_groups_views.GroupConfigurationsListView.as_view(), - name='group_configurations_list' - ), - re_path( - rf'^courses/{COURSE_ID_PATTERN}/group_configurations/(?P\d+)$', - course_groups_views.GroupConfigurationDetailView.as_view(), - name='group_configurations_detail' - ), re_path( rf'^courses/{COURSE_ID_PATTERN}/ora$', api_v2.ORAView.as_view(), diff --git a/lms/djangoapps/instructor/docs/references/instructor-v2-content-groups-spec.yaml b/openedx/core/djangoapps/course_groups/rest_api/docs/content-groups-api-v2-spec.yaml similarity index 95% rename from lms/djangoapps/instructor/docs/references/instructor-v2-content-groups-spec.yaml rename to openedx/core/djangoapps/course_groups/rest_api/docs/content-groups-api-v2-spec.yaml index 0b6cdfdeff54..e6ea6d54df93 100644 --- a/lms/djangoapps/instructor/docs/references/instructor-v2-content-groups-spec.yaml +++ b/openedx/core/djangoapps/course_groups/rest_api/docs/content-groups-api-v2-spec.yaml @@ -1,6 +1,6 @@ swagger: '2.0' info: - title: Instructor Dashboard Content Groups API v2 + title: Content Groups API v2 version: 2.0.0 description: | REST API for managing content group configurations. @@ -42,7 +42,7 @@ parameters: description: The ID of the content group configuration paths: - /api/instructor/v2/courses/{course_id}/group_configurations: + /api/cohorts/v2/courses/{course_id}/group_configurations: get: tags: - Content Groups @@ -69,7 +69,7 @@ paths: 404: description: Course not found - /api/instructor/v2/courses/{course_id}/group_configurations/{configuration_id}: + /api/cohorts/v2/courses/{course_id}/group_configurations/{configuration_id}: get: tags: - Content Groups diff --git a/openedx/core/djangoapps/course_groups/rest_api/tests/__init__.py b/openedx/core/djangoapps/course_groups/rest_api/tests/__init__.py new file mode 100644 index 000000000000..dcb1f76c123c --- /dev/null +++ b/openedx/core/djangoapps/course_groups/rest_api/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for Content Groups REST API v2. +""" diff --git a/lms/djangoapps/instructor/tests/views/test_group_configurations_v2.py b/openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py similarity index 85% rename from lms/djangoapps/instructor/tests/views/test_group_configurations_v2.py rename to openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py index 190723b205d8..c09f068718b3 100644 --- a/lms/djangoapps/instructor/tests/views/test_group_configurations_v2.py +++ b/openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py @@ -1,5 +1,5 @@ """ -Tests for Content Groups (Group Configurations) V2 API in Instructor Dashboard. +Tests for Content Groups REST API v2. """ from unittest.mock import patch @@ -11,11 +11,13 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from openedx.core.djangoapps.course_groups.constants import COHORT_SCHEME +from openedx.core.djangolib.testing.utils import skip_unless_lms +@skip_unless_lms class GroupConfigurationsListViewTestCase(ModuleStoreTestCase): """ - Tests for GET /api/instructor/v2/courses/{course_id}/group_configurations + Tests for GET /api/cohorts/v2/courses/{course_id}/group_configurations """ def setUp(self): @@ -28,14 +30,13 @@ def setUp(self): def _get_url(self, course_id=None): """Helper to get the list URL""" course_id = course_id or str(self.course.id) - return f'/api/instructor/v2/courses/{course_id}/group_configurations' + return f'/api/cohorts/v2/courses/{course_id}/group_configurations' @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') def test_list_content_groups_returns_json(self, mock_perm): """Verify endpoint returns JSON with correct structure""" mock_perm.return_value = True - # Create content groups self.course.user_partitions = [ UserPartition( id=50, @@ -50,10 +51,8 @@ def test_list_content_groups_returns_json(self, mock_perm): ] self.update_course(self.course, self.user.id) - # Get endpoint response = self.api_client.get(self._get_url()) - # Verify response self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response['Content-Type'], 'application/json') @@ -62,7 +61,6 @@ def test_list_content_groups_returns_json(self, mock_perm): self.assertIn('should_show_enrollment_track', data) self.assertIn('should_show_experiment_groups', data) - # Verify content groups configs = data['all_group_configurations'] self.assertEqual(len(configs), 1) self.assertEqual(configs[0]['scheme'], COHORT_SCHEME) @@ -73,7 +71,6 @@ def test_list_content_groups_filters_non_cohort_partitions(self, mock_perm): """Verify only cohort-scheme partitions are returned""" mock_perm.return_value = True - # Create mixed partitions self.course.user_partitions = [ UserPartition( id=50, @@ -87,7 +84,7 @@ def test_list_content_groups_filters_non_cohort_partitions(self, mock_perm): name='Experiment Groups', description='Random experiment groups', groups=[Group(id=1, name='Group B')], - scheme_id='random' # Not cohort scheme + scheme_id='random' ), ] self.update_course(self.course, self.user.id) @@ -97,7 +94,6 @@ def test_list_content_groups_filters_non_cohort_partitions(self, mock_perm): data = response.json() configs = data['all_group_configurations'] - # Should only return cohort-scheme partition self.assertEqual(len(configs), 1) self.assertEqual(configs[0]['id'], 50) self.assertEqual(configs[0]['scheme'], COHORT_SCHEME) @@ -107,20 +103,18 @@ def test_list_auto_creates_empty_content_group_if_none_exists(self, mock_perm): """Verify empty content group is auto-created when none exists""" mock_perm.return_value = True - # Don't add any partitions response = self.api_client.get(self._get_url()) data = response.json() configs = data['all_group_configurations'] - # Should have auto-created empty content group self.assertEqual(len(configs), 1) self.assertEqual(configs[0]['scheme'], COHORT_SCHEME) self.assertEqual(len(configs[0]['groups']), 0) def test_list_requires_authentication(self): """Verify endpoint requires authentication""" - client = APIClient() # Unauthenticated + client = APIClient() response = client.get(self._get_url()) self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) @@ -129,14 +123,14 @@ def test_list_invalid_course_key_returns_400(self, mock_perm): """Verify invalid course key returns 400""" mock_perm.return_value = True - # Use a course key that matches URL pattern but is invalid when parsed - response = self.api_client.get('/api/instructor/v2/courses/course-v1:org/course/run/group_configurations') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response = self.api_client.get('/api/cohorts/v2/courses/course-v1:invalid+course+key/group_configurations') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) +@skip_unless_lms class GroupConfigurationDetailViewTestCase(ModuleStoreTestCase): """ - Tests for GET /api/instructor/v2/courses/{course_id}/group_configurations/{id} + Tests for GET /api/cohorts/v2/courses/{course_id}/group_configurations/{id} """ def setUp(self): @@ -146,7 +140,6 @@ def setUp(self): self.course = CourseFactory.create() self.api_client.force_authenticate(user=self.user) - # Create a test configuration self.course.user_partitions = [ UserPartition( id=50, @@ -163,7 +156,7 @@ def setUp(self): def _get_url(self, configuration_id=50): """Helper to get detail URL""" - return f'/api/instructor/v2/courses/{self.course.id}/group_configurations/{configuration_id}' + return f'/api/cohorts/v2/courses/{self.course.id}/group_configurations/{configuration_id}' @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') def test_get_configuration_details(self, mock_perm): @@ -181,6 +174,7 @@ def test_get_configuration_details(self, mock_perm): self.assertEqual(len(data['groups']), 2) +@skip_unless_lms class ContentGroupsPermissionsTestCase(ModuleStoreTestCase): """ Tests for permission checking @@ -194,7 +188,7 @@ def setUp(self): def _get_url(self): """Helper to get list URL""" - return f'/api/instructor/v2/courses/{self.course.id}/group_configurations' + return f'/api/cohorts/v2/courses/{self.course.id}/group_configurations' @patch('lms.djangoapps.instructor.permissions.InstructorPermission.has_permission') def test_staff_user_can_access(self, mock_perm): diff --git a/openedx/core/djangoapps/course_groups/rest_api/urls.py b/openedx/core/djangoapps/course_groups/rest_api/urls.py new file mode 100644 index 000000000000..23563c341e00 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/rest_api/urls.py @@ -0,0 +1,20 @@ +""" +Content Groups REST API v2 URLs +""" +from django.conf import settings +from django.urls import re_path + +from openedx.core.djangoapps.course_groups.rest_api import views + +urlpatterns = [ + re_path( + r'^v2/courses/{}/group_configurations$'.format(settings.COURSE_KEY_PATTERN), + views.GroupConfigurationsListView.as_view(), + name='group_configurations_list' + ), + re_path( + r'^v2/courses/{}/group_configurations/(?P\d+)$'.format(settings.COURSE_KEY_PATTERN), + views.GroupConfigurationDetailView.as_view(), + name='group_configurations_detail' + ), +] diff --git a/openedx/core/djangoapps/course_groups/rest_api/views.py b/openedx/core/djangoapps/course_groups/rest_api/views.py index f49a18b09739..c880d713b423 100644 --- a/openedx/core/djangoapps/course_groups/rest_api/views.py +++ b/openedx/core/djangoapps/course_groups/rest_api/views.py @@ -50,15 +50,15 @@ class GroupConfigurationsListView(DeveloperErrorViewMixin, APIView): 404: "Course not found", }, ) - def get(self, request, course_id): + def get(self, request, course_key_string): """ List all content groups for a course. """ try: - course_key = CourseKey.from_string(course_id) + course_key = CourseKey.from_string(course_key_string) except InvalidKeyError: return Response( - {"error": f"Invalid course key: {course_id}"}, + {"error": f"Invalid course key: {course_key_string}"}, status=status.HTTP_400_BAD_REQUEST ) @@ -66,7 +66,7 @@ def get(self, request, course_id): course = get_course_by_id(course_key) except ItemNotFoundError: return Response( - {"error": f"Course not found: {course_id}"}, + {"error": f"Course not found: {course_key_string}"}, status=status.HTTP_404_NOT_FOUND ) @@ -87,8 +87,8 @@ def get(self, request, course_id): "should_show_enrollment_track": False, "should_show_experiment_groups": True, "context_course": None, - "group_configuration_url": f"/api/instructor/v2/courses/{course_id}/group_configurations", - "course_outline_url": f"/api/contentstore/v1/courses/{course_id}", + "group_configuration_url": f"/api/cohorts/v2/courses/{course_key_string}/group_configurations", + "course_outline_url": f"/api/contentstore/v1/courses/{course_key_string}", } serializer = ContentGroupsListResponseSerializer(context) @@ -123,15 +123,15 @@ class GroupConfigurationDetailView(DeveloperErrorViewMixin, APIView): 404: "Content group configuration not found", }, ) - def get(self, request, course_id, configuration_id): + def get(self, request, course_key_string, configuration_id): """ Retrieve a specific content group configuration. """ try: - course_key = CourseKey.from_string(course_id) + course_key = CourseKey.from_string(course_key_string) except InvalidKeyError: return Response( - {"error": f"Invalid course key: {course_id}"}, + {"error": f"Invalid course key: {course_key_string}"}, status=status.HTTP_400_BAD_REQUEST ) @@ -139,7 +139,7 @@ def get(self, request, course_id, configuration_id): course = get_course_by_id(course_key) except ItemNotFoundError: return Response( - {"error": f"Course not found: {course_id}"}, + {"error": f"Course not found: {course_key_string}"}, status=status.HTTP_404_NOT_FOUND ) diff --git a/openedx/core/djangoapps/course_groups/urls.py b/openedx/core/djangoapps/course_groups/urls.py index dc64b16f7960..46fb0db85d80 100644 --- a/openedx/core/djangoapps/course_groups/urls.py +++ b/openedx/core/djangoapps/course_groups/urls.py @@ -4,7 +4,7 @@ from django.conf import settings -from django.urls import re_path +from django.urls import include, re_path import lms.djangoapps.instructor.views.api import openedx.core.djangoapps.course_groups.views @@ -38,4 +38,6 @@ lms.djangoapps.instructor.views.api.CohortCSV.as_view(), name='cohort_users_csv', ), + # v2 Content Groups API + re_path(r'', include('openedx.core.djangoapps.course_groups.rest_api.urls')), ]