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/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index 58948af61400..7fcf2c6aa021 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -475,3 +475,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/docs/content-groups-api-v2-spec.yaml b/openedx/core/djangoapps/course_groups/rest_api/docs/content-groups-api-v2-spec.yaml new file mode 100644 index 000000000000..e6ea6d54df93 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/rest_api/docs/content-groups-api-v2-spec.yaml @@ -0,0 +1,172 @@ +swagger: '2.0' +info: + title: 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/cohorts/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/cohorts/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/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/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/openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py b/openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py new file mode 100644 index 000000000000..c09f068718b3 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py @@ -0,0 +1,208 @@ +""" +Tests for Content Groups REST API v2. +""" +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 +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +@skip_unless_lms +class GroupConfigurationsListViewTestCase(ModuleStoreTestCase): + """ + Tests for GET /api/cohorts/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/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 + + 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) + + response = self.api_client.get(self._get_url()) + + 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) + + 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 + + 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' + ), + ] + self.update_course(self.course, self.user.id) + + response = self.api_client.get(self._get_url()) + + data = response.json() + configs = data['all_group_configurations'] + + 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 + + response = self.api_client.get(self._get_url()) + + data = response.json() + configs = data['all_group_configurations'] + + 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() + 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 + + 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/cohorts/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) + + 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/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): + """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) + + +@skip_unless_lms +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/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): + """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/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..cb9e277c71c2 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/rest_api/urls.py @@ -0,0 +1,20 @@ +""" +Content Groups REST API v2 URLs +""" +from django.urls import re_path + +from openedx.core.constants import COURSE_ID_PATTERN +from openedx.core.djangoapps.course_groups.rest_api import views + +urlpatterns = [ + re_path( + fr'^v2/courses/{COURSE_ID_PATTERN}/group_configurations$', + views.GroupConfigurationsListView.as_view(), + name='group_configurations_list' + ), + re_path( + fr'^v2/courses/{COURSE_ID_PATTERN}/group_configurations/(?P\d+)$', + 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 new file mode 100644 index 000000000000..f44705629b96 --- /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/cohorts/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) 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')), ]