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/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index ff6d7c04aa5b..76923b23659c 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -8,6 +8,7 @@ import logging 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, UsageKey from rest_framework import status @@ -21,19 +22,31 @@ from django.utils.html import strip_tags from django.utils.translation import gettext as _ from common.djangoapps.util.json_request import JsonResponseBadRequest +from openedx.core.djangoapps.course_groups.constants import ( + CONTENT_GROUP_CONFIGURATION_NAME, + CONTENT_GROUP_CONFIGURATION_DESCRIPTION, + 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 +) +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.partitions.partitions import MINIMUM_UNUSED_PARTITION_ID, UserPartition from lms.djangoapps.courseware.tabs import get_course_tab_list from lms.djangoapps.instructor import permissions +from lms.djangoapps.instructor.ora import get_open_response_assessment_list, get_ora_summary from lms.djangoapps.instructor.views.api import _display_unit, get_student_from_identifier from lms.djangoapps.instructor.views.instructor_task_helpers import extract_task_features from lms.djangoapps.instructor_task import api as task_api -from lms.djangoapps.instructor.ora import get_open_response_assessment_list, get_ora_summary from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id from .serializers_v2 import ( - InstructorTaskListSerializer, - CourseInformationSerializerV2, BlockDueDateSerializerV2, + CourseInformationSerializerV2, + InstructorTaskListSerializer, ORASerializer, ORASummarySerializer, ) @@ -77,76 +90,6 @@ def get(self, request, course_id): """ Retrieve comprehensive course information including metadata, enrollment statistics, dashboard configuration, and user permissions. - - **Use Cases** - - Retrieve comprehensive course metadata including enrollment counts, dashboard configuration, - permissions, and navigation sections. - - **Example Requests** - - GET /api/instructor/v2/courses/{course_id} - - **Response Values** - - { - "course_id": "course-v1:edX+DemoX+Demo_Course", - "display_name": "Demonstration Course", - "org": "edX", - "course_number": "DemoX", - "enrollment_start": "2013-02-05T00:00:00Z", - "enrollment_end": null, - "start": "2013-02-05T05:00:00Z", - "end": "2024-12-31T23:59:59Z", - "pacing": "instructor", - "has_started": true, - "has_ended": false, - "total_enrollment": 150, - "enrollment_counts": { - "total": 150, - "audit": 100, - "verified": 40, - "honor": 10 - }, - "num_sections": 12, - "grade_cutoffs": "A is 0.9, B is 0.8, C is 0.7, D is 0.6", - "course_errors": [], - "studio_url": "https://studio.example.com/course/course-v1:edX+DemoX+2024", - "permissions": { - "admin": false, - "instructor": true, - "finance_admin": false, - "sales_admin": false, - "staff": true, - "forum_admin": true, - "data_researcher": false - }, - "tabs": [ - { - "tab_id": "courseware", - "title": "Course", - "url": "INSTRUCTOR_MICROFRONTEND_URL/courses/course-v1:edX+DemoX+2024/courseware" - }, - { - "tab_id": "progress", - "title": "Progress", - "url": "INSTRUCTOR_MICROFRONTEND_URL/courses/course-v1:edX+DemoX+2024/progress" - }, - ], - "disable_buttons": false, - "analytics_dashboard_message": "To gain insights into student enrollment and participation..." - } - - **Parameters** - - course_key: Course key for the course. - - **Returns** - - * 200: OK - Returns course metadata - * 401: Unauthorized - User is not authenticated - * 403: Forbidden - User lacks instructor permissions - * 404: Not Found - Course does not exist """ course_key = CourseKey.from_string(course_id) course = get_course_by_id(course_key) @@ -168,46 +111,6 @@ class InstructorTaskListView(DeveloperErrorViewMixin, APIView): **Use Cases** List instructor tasks for a course. - - **Example Requests** - - GET /api/instructor/v2/courses/{course_key}/instructor_tasks - GET /api/instructor/v2/courses/{course_key}/instructor_tasks?problem_location_str=block-v1:... - GET /api/instructor/v2/courses/{course_key}/instructor_tasks? - problem_location_str=block-v1:...&unique_student_identifier=student@example.com - - **Response Values** - - { - "tasks": [ - { - "task_id": "2519ff31-22d9-4a62-91e2-55495895b355", - "task_type": "grade_problems", - "task_state": "PROGRESS", - "status": "Incomplete", - "created": "2019-01-15T18:00:15.902470+00:00", - "task_input": "{}", - "task_output": null, - "duration_sec": "unknown", - "task_message": "No status information available", - "requester": "staff" - } - ] - } - - **Parameters** - - course_key: Course key for the course. - problem_location_str (optional): Filter tasks to a specific problem location. - unique_student_identifier (optional): Filter tasks to specific student (must be used with problem_location_str). - - **Returns** - - * 200: OK - Returns list of instructor tasks - * 400: Bad Request - Invalid parameters - * 401: Unauthorized - User is not authenticated - * 403: Forbidden - User lacks instructor permissions - * 404: Not Found - Course does not exist """ permission_classes = (IsAuthenticated, permissions.InstructorPermission) @@ -444,3 +347,147 @@ def get(self, request, *args, **kwargs): serializer = self.get_serializer(items) return Response(serializer.data) + + +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. + + Returns all content group configurations (scheme='cohort') along with + context about whether to show enrollment tracks and experiment groups. + + If no content group exists, an empty content group partition is automatically created. + """ + 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 + ) + + # Get or create content group partition + content_group_partition = get_cohorted_user_partition(course) + + if content_group_partition is None: + # Auto-create empty content group if none exists + 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 + ) + + # Build response context + 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/instructor/v2/courses/{course_id}/group_configurations", + "course_outline_url": f"/api/contentstore/v1/courses/{course_id}", + } + + # Serialize and return + 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 + ) + + # Find the configuration + 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 + ) + + # Serialize and return + response_data = partition.to_json() + serializer = ContentGroupConfigurationSerializer(response_data) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index 414079007291..e8b528bfc482 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -448,3 +448,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..23563c341e00 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/rest_api/urls.py @@ -0,0 +1,20 @@ +""" +Content Groups REST API v2 URLs +""" +from django.conf import settings +from django.urls import re_path + +from openedx.core.djangoapps.course_groups.rest_api import views + +urlpatterns = [ + re_path( + r'^v2/courses/{}/group_configurations$'.format(settings.COURSE_KEY_PATTERN), + views.GroupConfigurationsListView.as_view(), + name='group_configurations_list' + ), + re_path( + r'^v2/courses/{}/group_configurations/(?P\d+)$'.format(settings.COURSE_KEY_PATTERN), + 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..c880d713b423 --- /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_key_string): + """ + List all content groups for a course. + """ + try: + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + return Response( + {"error": f"Invalid course key: {course_key_string}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + course = get_course_by_id(course_key) + except ItemNotFoundError: + return Response( + {"error": f"Course not found: {course_key_string}"}, + 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_key_string}/group_configurations", + "course_outline_url": f"/api/contentstore/v1/courses/{course_key_string}", + } + + 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_key_string, configuration_id): + """ + Retrieve a specific content group configuration. + """ + try: + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + return Response( + {"error": f"Invalid course key: {course_key_string}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + course = get_course_by_id(course_key) + except ItemNotFoundError: + return Response( + {"error": f"Course not found: {course_key_string}"}, + 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')), ]