Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
17 changes: 11 additions & 6 deletions openedx/core/djangoapps/course_groups/rest_api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
68 changes: 46 additions & 22 deletions openedx/core/djangoapps/course_groups/rest_api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"""
Expand All @@ -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 = [
Expand All @@ -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):
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test doesn't verify the studio_content_groups_link format when no content groups exist. Add an assertion using @override_settings decorator (like in test_list_content_groups_returns_json) to verify the Studio URL is correctly constructed even when no partition exists.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

"""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"""
Expand Down
50 changes: 23 additions & 27 deletions openedx/core/djangoapps/course_groups/rest_api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)


Expand Down
Loading