From 4f029d4178f5c16807de3dd113bceebb18945b2e Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Tue, 3 Feb 2026 09:55:57 -0700 Subject: [PATCH] feat: Simplify content groups v2 response JSON --- .../docs/content-groups-api-v2-spec.yaml | 29 ++++---- .../course_groups/rest_api/serializers.py | 17 +++-- .../rest_api/tests/test_views.py | 68 +++++++++++++------ .../course_groups/rest_api/views.py | 50 +++++++------- 4 files changed, 92 insertions(+), 72 deletions(-) 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 index e6ea6d54df93..633d9fdddf01 100644 --- 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 @@ -46,10 +46,10 @@ paths: get: tags: - Content Groups - summary: List content group configurations + summary: List content groups description: | - Returns all content group configurations (scheme='cohort') for a course. - If no content group exists, an empty one is automatically created. + Returns the content groups for a course along with the configuration ID + and a link to Studio for managing content groups. operationId: listGroupConfigurations produces: - application/json @@ -153,20 +153,15 @@ definitions: ContentGroupsListResponse: type: object properties: - all_group_configurations: + id: + type: integer + nullable: true + description: ID of the content group configuration (null if none exists) + groups: 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: + $ref: '#/definitions/Group' + description: Flat list of content groups for the course + studio_content_groups_link: type: string - description: URL to the course outline + description: Full URL to Studio's content group configuration page diff --git a/openedx/core/djangoapps/course_groups/rest_api/serializers.py b/openedx/core/djangoapps/course_groups/rest_api/serializers.py index 651d58a966da..e9403f8b81cd 100644 --- a/openedx/core/djangoapps/course_groups/rest_api/serializers.py +++ b/openedx/core/djangoapps/course_groups/rest_api/serializers.py @@ -36,10 +36,15 @@ class ContentGroupConfigurationSerializer(serializers.Serializer): class ContentGroupsListResponseSerializer(serializers.Serializer): """ Response serializer for listing all content groups. + + Returns the content group configuration ID, a flat list of content groups, + and a link to Studio where instructors can manage 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() + id = serializers.IntegerField( + allow_null=True, + help_text="ID of the content group configuration (null if none exists)" + ) + groups = GroupSerializer(many=True) + studio_content_groups_link = serializers.CharField( + help_text="Full URL to Studio's content group configuration page" + ) 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 index c09f068718b3..a3b1505aa389 100644 --- a/openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py +++ b/openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py @@ -3,15 +3,18 @@ """ from unittest.mock import patch +from django.test import override_settings 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 +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.partitions.partitions import Group, UserPartition + +TEST_STUDIO_BASE_URL = "https://studio.example.com" @skip_unless_lms @@ -28,10 +31,16 @@ def setUp(self): self.api_client.force_authenticate(user=self.user) def _get_url(self, course_id=None): - """Helper to get the list URL""" + """Helper to get the API URL""" course_id = course_id or str(self.course.id) return f'/api/cohorts/v2/courses/{course_id}/group_configurations' + def _get_expected_studio_url(self, course_id=None): + """Helper to get the expected Studio URL""" + course_id = course_id or str(self.course.id) + return f'{TEST_STUDIO_BASE_URL}/course/{course_id}/group_configurations' + + @override_settings(MFE_CONFIG={"STUDIO_BASE_URL": TEST_STUDIO_BASE_URL}) @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""" @@ -57,18 +66,26 @@ def test_list_content_groups_returns_json(self, mock_perm): 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) + self.assertIn('id', data) + self.assertIn('groups', data) + self.assertIn('studio_content_groups_link', data) + + # Verify partition ID is returned + self.assertEqual(data['id'], 50) + + # Verify groups + groups = data['groups'] + self.assertEqual(len(groups), 2) + self.assertEqual(groups[0]['name'], 'Content Group A') + self.assertEqual(groups[1]['name'], 'Content Group B') - configs = data['all_group_configurations'] - self.assertEqual(len(configs), 1) - self.assertEqual(configs[0]['scheme'], COHORT_SCHEME) - self.assertEqual(len(configs[0]['groups']), 2) + # Verify full Studio URL + expected_studio_url = self._get_expected_studio_url() + self.assertEqual(data['studio_content_groups_link'], expected_studio_url) @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""" + """Verify only groups from cohort-scheme partitions are returned""" mock_perm.return_value = True self.course.user_partitions = [ @@ -92,25 +109,32 @@ def test_list_content_groups_filters_non_cohort_partitions(self, mock_perm): 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) + # Verify cohort partition ID is returned + self.assertEqual(data['id'], 50) + + # Only groups from cohort partition should be returned + groups = data['groups'] + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]['name'], 'Group A') + @override_settings(MFE_CONFIG={"STUDIO_BASE_URL": TEST_STUDIO_BASE_URL}) @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""" + def test_list_returns_empty_groups_when_none_exist(self, mock_perm): + """Verify empty groups array and null id when no content groups exist""" 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) + # ID should be null when no partition exists + self.assertIsNone(data['id']) + self.assertEqual(len(data['groups']), 0) + + # Verify full Studio URL + expected_studio_url = self._get_expected_studio_url() + self.assertEqual(data['studio_content_groups_link'], expected_studio_url) def test_list_requires_authentication(self): """Verify endpoint requires authentication""" diff --git a/openedx/core/djangoapps/course_groups/rest_api/views.py b/openedx/core/djangoapps/course_groups/rest_api/views.py index f44705629b96..47631a64e2bb 100644 --- a/openedx/core/djangoapps/course_groups/rest_api/views.py +++ b/openedx/core/djangoapps/course_groups/rest_api/views.py @@ -2,29 +2,25 @@ 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 django.conf import settings 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.constants import 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, + ContentGroupsListResponseSerializer ) +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id +from xmodule.modulestore.exceptions import ItemNotFoundError class GroupConfigurationsListView(DeveloperErrorViewMixin, APIView): @@ -72,26 +68,26 @@ def get(self, request, course_id): 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}", + # Extract partition ID and groups, or None/empty list if no partition exists + if content_group_partition is not None: + partition_id = content_group_partition.id + groups = [group.to_json() for group in content_group_partition.groups] + else: + partition_id = None + groups = [] + + # Build full Studio URL for content group configuration + mfe_config = configuration_helpers.get_value("MFE_CONFIG", settings.MFE_CONFIG) + studio_base_url = mfe_config.get("STUDIO_BASE_URL", "") + studio_content_groups_link = f"{studio_base_url}/course/{course_id}/group_configurations" + + response_data = { + "id": partition_id, + "groups": groups, + "studio_content_groups_link": studio_content_groups_link, } - serializer = ContentGroupsListResponseSerializer(context) + serializer = ContentGroupsListResponseSerializer(response_data) return Response(serializer.data, status=status.HTTP_200_OK)