fix: Add group_id and user_partition_id support to Cohorts API v1 (#37982)

This commit is contained in:
brianjbuck-wgu
2026-02-17 15:09:51 -07:00
committed by GitHub
parent b98e41e339
commit 43690553f6
3 changed files with 333 additions and 30 deletions

View File

@@ -629,10 +629,9 @@ paths:
parameters: []
responses:
'200':
description: ''
description: Successful response with cohort details.
schema:
type: object
properties: {}
$ref: '#/definitions/Cohort'
tags:
- cohorts
post:
@@ -643,32 +642,26 @@ paths:
in: body
required: true
schema:
type: object
properties: {}
$ref: '#/definitions/CohortCreate'
responses:
'201':
description: ''
'200':
description: Successful response with created cohort details.
schema:
type: object
properties: {}
$ref: '#/definitions/Cohort'
tags:
- cohorts
patch:
operationId: cohorts_v1_courses_cohorts_partial_update
description: Endpoint to update a cohort name and/or assignment type.
description: Endpoint to update a cohort name, assignment type, and/or content group association.
parameters:
- name: data
in: body
required: true
schema:
type: object
properties: {}
$ref: '#/definitions/CohortUpdate'
responses:
'200':
description: ''
schema:
type: object
properties: {}
'204':
description: Successful update, no content returned.
tags:
- cohorts
parameters:
@@ -11040,6 +11033,91 @@ paths:
required: true
type: string
definitions:
Cohort:
description: A cohort representation
type: object
properties:
id:
title: ID
description: The integer identifier for a cohort.
type: integer
name:
title: Name
description: The string identifier for a cohort.
type: string
user_count:
title: User Count
description: The number of students in the cohort.
type: integer
assignment_type:
title: Assignment Type
description: The assignment type ("manual" or "random").
type: string
enum:
- manual
- random
user_partition_id:
title: User Partition ID
description: The integer identifier of the UserPartition (content group configuration).
type: integer
x-nullable: true
group_id:
title: Group ID
description: The integer identifier of the specific group in the partition.
type: integer
x-nullable: true
CohortCreate:
description: Request body for creating a new cohort
required:
- name
- assignment_type
type: object
properties:
name:
title: Name
description: The string identifier for a cohort.
type: string
minLength: 1
assignment_type:
title: Assignment Type
description: The assignment type ("manual" or "random").
type: string
enum:
- manual
- random
user_partition_id:
title: User Partition ID
description: The integer identifier of the UserPartition (content group configuration). Required if group_id is specified.
type: integer
group_id:
title: Group ID
description: The integer identifier of the specific group in the partition.
type: integer
CohortUpdate:
description: Request body for updating a cohort. At least one of name, assignment_type, or group_id must be provided.
type: object
properties:
name:
title: Name
description: The string identifier for a cohort.
type: string
minLength: 1
assignment_type:
title: Assignment Type
description: The assignment type ("manual" or "random").
type: string
enum:
- manual
- random
user_partition_id:
title: User Partition ID
description: The integer identifier of the UserPartition (content group configuration). Required if group_id is specified (non-null).
type: integer
group_id:
title: Group ID
description: The integer identifier of the specific group in the partition. Set to null to remove the content group association.
type: integer
x-nullable: true
PermissionValidation:
description: The permissions to validate
required:

View File

@@ -3,7 +3,6 @@ Tests for Cohort API
"""
import json
import tempfile
import ddt
@@ -15,8 +14,9 @@ from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import ToyCourseFactory # lint-amnesty, pylint: disable=wrong-import-order
from .. import cohorts
from .helpers import CohortFactory
from openedx.core.djangoapps.course_groups import cohorts
from openedx.core.djangoapps.course_groups.views import link_cohort_to_partition_group
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
USERNAME = 'honor'
USER_MAIL = 'honor@example.com'
@@ -24,7 +24,7 @@ SETTINGS_PAYLOAD = '{"is_cohorted": true}'
HANDLER_POST_PAYLOAD = '{"name":"Default","user_count":0,"assignment_type":"random","user_partition_id":null\
,"group_id":null}'
HANDLER_PATCH_PAYLOAD = '{"name":"Default Group","group_id":null,"user_partition_id":null,"assignment_type":"random"}'
ADD_USER_PAYLOAD = json.dumps({'users': [USER_MAIL, ]})
ADD_USER_PAYLOAD = {'users': [USER_MAIL, ]}
CSV_DATA = f'''email,cohort\n{USER_MAIL},DEFAULT'''
@@ -307,7 +307,7 @@ class TestCohortApi(SharedModuleStoreTestCase):
assert response.status_code == status
if status == 200:
results = json.loads(response.content.decode('utf-8'))['results']
results = response.json()['results']
expected_results = [{
'username': user.username,
'email': user.email,
@@ -406,7 +406,7 @@ class TestCohortApi(SharedModuleStoreTestCase):
"invalid": ["foo@bar"],
"present": ["user2"]
}
assert json.loads(response.content.decode('utf-8')) == expected_response
assert response.json() == expected_response
def test_remove_user_from_cohort_missing_username(self):
"""
@@ -458,3 +458,151 @@ class TestCohortApi(SharedModuleStoreTestCase):
response = self.client.post(path=path,
data={'uploaded-file': file_pointer})
assert response.status_code == status
def test_post_cohort_with_group_id(self):
"""
Test creating a cohort with group_id and user_partition_id.
"""
path = reverse('api_cohorts:cohort_handler', kwargs={'course_key_string': self.course_str})
self.client.login(username=self.staff_user.username, password=self.password)
payload = {
'name': 'TestCohort',
'assignment_type': 'manual',
'group_id': 1,
'user_partition_id': 50
}
response = self.client.post(path=path, data=payload, content_type='application/json')
assert response.status_code == 200
data = response.json()
assert data['name'] == 'TestCohort'
assert data['assignment_type'] == 'manual'
assert data['group_id'] == 1
assert data['user_partition_id'] == 50
assert data['user_count'] == 0
assert 'id' in data
def test_post_cohort_with_group_id_missing_partition_id(self):
"""
Test that creating a cohort with group_id but without user_partition_id returns an error.
"""
path = reverse('api_cohorts:cohort_handler', kwargs={'course_key_string': self.course_str})
self.client.login(username=self.staff_user.username, password=self.password)
payload = {
'name': 'TestCohort',
'assignment_type': 'manual',
'group_id': 1
}
response = self.client.post(path=path, data=payload, content_type='application/json')
assert response.status_code == 400
data = response.json()
assert data['developer_message'] == 'If group_id is specified, user_partition_id must also be specified.'
assert data['error_code'] == 'missing-user-partition-id'
def test_patch_cohort_set_group_id(self):
"""
Test updating a cohort to set group_id and user_partition_id.
"""
cohort = cohorts.add_cohort(self.course_key, "TestCohort", "manual")
path = reverse(
'api_cohorts:cohort_handler',
kwargs={'course_key_string': self.course_str, 'cohort_id': cohort.id}
)
self.client.login(username=self.staff_user.username, password=self.password)
payload = {
'group_id': 2,
'user_partition_id': 50
}
response = self.client.patch(path=path, data=payload, content_type='application/json')
assert response.status_code == 204
# Verify by fetching the cohort
response = self.client.get(path=path)
data = response.json()
assert data['id'] == cohort.id
assert data['name'] == 'TestCohort'
assert data['assignment_type'] == 'manual'
assert data['group_id'] == 2
assert data['user_partition_id'] == 50
def test_patch_cohort_remove_group_id(self):
"""
Test updating a cohort to remove the group_id association by setting it to null.
"""
cohort = cohorts.add_cohort(self.course_key, "TestCohort", "manual")
link_cohort_to_partition_group(cohort, 50, 1)
path = reverse(
'api_cohorts:cohort_handler',
kwargs={'course_key_string': self.course_str, 'cohort_id': cohort.id}
)
self.client.login(username=self.staff_user.username, password=self.password)
# Verify the cohort has a group_id
response = self.client.get(path=path)
data = response.json()
assert data['id'] == cohort.id
assert data['name'] == 'TestCohort'
assert data['group_id'] == 1
assert data['user_partition_id'] == 50
# Remove the group_id by setting it to null
payload = {'group_id': None}
response = self.client.patch(path=path, data=payload, content_type='application/json')
assert response.status_code == 204
# Verify the group_id was removed but other fields unchanged
response = self.client.get(path=path)
data = response.json()
assert data['id'] == cohort.id
assert data['name'] == 'TestCohort'
assert data['assignment_type'] == 'manual'
assert data['group_id'] is None
assert data['user_partition_id'] is None
def test_patch_cohort_with_group_id_missing_partition_id(self):
"""
Test that updating a cohort with group_id but without user_partition_id returns an error.
"""
cohort = cohorts.add_cohort(self.course_key, "TestCohort", "manual")
path = reverse(
'api_cohorts:cohort_handler',
kwargs={'course_key_string': self.course_str, 'cohort_id': cohort.id}
)
self.client.login(username=self.staff_user.username, password=self.password)
payload = {'group_id': 2}
response = self.client.patch(path=path, data=payload, content_type='application/json')
assert response.status_code == 400
data = response.json()
assert data['developer_message'] == 'If group_id is specified, user_partition_id must also be specified.'
assert data['error_code'] == 'missing-user-partition-id'
def test_patch_cohort_with_name_only(self):
"""
Test that PATCH with only name is now valid (previously required assignment_type too).
"""
cohort = cohorts.add_cohort(self.course_key, "OldName", "manual")
path = reverse(
'api_cohorts:cohort_handler',
kwargs={'course_key_string': self.course_str, 'cohort_id': cohort.id}
)
self.client.login(username=self.staff_user.username, password=self.password)
payload = {'name': 'NewName'}
response = self.client.patch(path=path, data=payload, content_type='application/json')
assert response.status_code == 204
# Verify the name was updated and other fields unchanged
response = self.client.get(path=path)
data = response.json()
assert data['id'] == cohort.id
assert data['name'] == 'NewName'
assert data['assignment_type'] == 'manual'
assert data['group_id'] is None
assert data['user_partition_id'] is None

View File

@@ -424,6 +424,44 @@ def _get_cohort_response(cohort, course):
return Response(_get_cohort_representation(cohort, course), status=status.HTTP_200_OK)
def _update_cohort_partition_group(cohort, group_id, user_partition_id, api_error_func):
"""
Helper method to update the partition group association for a cohort.
Note: This logic is duplicated from the legacy cohort_handler function (lines 218-234).
We chose not to refactor cohort_handler to use this helper because:
1. cohort_handler returns JsonResponse for errors, while this helper uses DRF's api_error pattern
2. Unifying the error handling would require changing cohort_handler's interface or adding
try/except blocks, which could have downstream effects on existing callers
3. The legacy endpoint may be deprecated in favor of the v1 API in the future
Args:
cohort: The cohort to update
group_id: The group_id from the request (can be None to unlink, or an integer to link)
user_partition_id: The user_partition_id from the request
api_error_func: Function to call to raise API errors (e.g., self.api_error)
Raises:
API error if group_id is provided without user_partition_id
"""
if group_id is not None:
if user_partition_id is None:
raise api_error_func(
status.HTTP_400_BAD_REQUEST,
'If group_id is specified, user_partition_id must also be specified.',
'missing-user-partition-id'
)
existing_group_id, existing_partition_id = cohorts.get_group_info_for_cohort(cohort)
if group_id != existing_group_id or user_partition_id != existing_partition_id:
unlink_cohort_partition_group(cohort)
link_cohort_to_partition_group(cohort, user_partition_id, group_id)
else:
# If group_id was explicitly set to None, unlink the cohort from any partition group
existing_group_id, _ = cohorts.get_group_info_for_cohort(cohort)
if existing_group_id is not None:
unlink_cohort_partition_group(cohort)
def _get_cohort_settings_response(course_key):
"""
Helper method to return a serialized response for the cohort settings.
@@ -499,6 +537,25 @@ class CohortHandler(DeveloperErrorViewMixin, APIPermissions):
GET /api/cohorts/v1/courses/{course_id}/cohorts/{cohort_id}
PATCH /api/cohorts/v1/courses/{course_id}/cohorts/{cohort_id}
**POST Request Values**
* name (required): The string identifier for a cohort.
* assignment_type (required): The string representing the assignment type ("manual" or "random").
* user_partition_id (optional): The integer identifier of the UserPartition (content group configuration).
* group_id (optional): The integer identifier of the specific group in the partition.
If group_id is specified, user_partition_id must also be specified.
**PATCH Request Values**
* name (optional): The string identifier for a cohort.
* assignment_type (optional): The string representing the assignment type ("manual" or "random").
* user_partition_id (optional): The integer identifier of the UserPartition (content group configuration).
* group_id (optional): The integer identifier of the specific group in the partition.
Set group_id to null to remove the content group association.
If group_id is specified (non-null), user_partition_id must also be specified.
At least one of name, assignment_type, or group_id must be provided.
**Response Values**
* cohorts: List of cohorts.
@@ -507,8 +564,8 @@ class CohortHandler(DeveloperErrorViewMixin, APIPermissions):
* id: The integer identifier for a cohort.
* user_count: The number of students in the cohort.
* assignment_type: The string representing the assignment type.
* user_partition_id: The integer identified of the UserPartition.
* group_id: The integer identified of the specific group in the partition.
* user_partition_id: The integer identifier of the UserPartition.
* group_id: The integer identifier of the specific group in the partition.
"""
queryset = []
@@ -547,12 +604,20 @@ class CohortHandler(DeveloperErrorViewMixin, APIPermissions):
raise self.api_error(status.HTTP_400_BAD_REQUEST,
'"assignment_type" must be specified to create cohort.',
'missing-assignment-type')
return _get_cohort_response(
cohorts.add_cohort(course_key, name, assignment_type), course)
cohort = cohorts.add_cohort(course_key, name, assignment_type)
# Handle optional group_id and user_partition_id for content group association
group_id = request.data.get('group_id')
user_partition_id = request.data.get('user_partition_id')
if group_id is not None or user_partition_id is not None:
_update_cohort_partition_group(cohort, group_id, user_partition_id, self.api_error)
return _get_cohort_response(cohort, course)
def patch(self, request, course_key_string, cohort_id=None):
"""
Endpoint to update a cohort name and/or assignment type.
Endpoint to update a cohort name, assignment type, and/or content group association.
"""
if cohort_id is None:
raise self.api_error(status.HTTP_405_METHOD_NOT_ALLOWED,
@@ -560,9 +625,16 @@ class CohortHandler(DeveloperErrorViewMixin, APIPermissions):
'missing-cohort-id')
name = request.data.get('name')
assignment_type = request.data.get('assignment_type')
if not any((name, assignment_type)):
# has_group_id checks key presence rather than truthiness because
# group_id=null is a valid request to unlink the content group association,
# which is distinct from group_id not being sent at all (no change).
has_group_id = 'group_id' in request.data
group_id = request.data.get('group_id')
user_partition_id = request.data.get('user_partition_id')
if not any((name, assignment_type, has_group_id)):
raise self.api_error(status.HTTP_400_BAD_REQUEST,
'Request must include name and/or assignment type.',
'Request must include name, assignment_type, and/or group_id.',
'missing-fields')
course_key, _ = _get_course_with_access(request, course_key_string)
cohort = cohorts.get_cohort_by_id(course_key, cohort_id)
@@ -578,6 +650,11 @@ class CohortHandler(DeveloperErrorViewMixin, APIPermissions):
cohorts.set_assignment_type(cohort, assignment_type)
except ValueError as e:
raise self.api_error(status.HTTP_400_BAD_REQUEST, str(e), 'last-random-cohort')
# Handle group_id and user_partition_id for content group association
if has_group_id:
_update_cohort_partition_group(cohort, group_id, user_partition_id, self.api_error)
return Response(status=status.HTTP_204_NO_CONTENT)