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
This commit is contained in:
Brian Buck
2026-01-29 10:20:22 -07:00
committed by Feanil Patel
parent 6cb2ea3cf1
commit c9704c28ee
10 changed files with 632 additions and 11 deletions

View File

@@ -12,6 +12,14 @@ from django.utils.translation import gettext as _
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 @@ from xmodule.split_test_block import get_split_user_partitions # lint-amnesty,
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__)

View File

@@ -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.'
)

View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
"""
Tests for Content Groups REST API v2.
"""

View File

@@ -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])

View File

@@ -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<configuration_id>\d+)$',
views.GroupConfigurationDetailView.as_view(),
name='group_configurations_detail'
),
]

View File

@@ -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)

View File

@@ -4,7 +4,7 @@ Cohort API URLs
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 @@ urlpatterns = [
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')),
]