From 1a7b8584ad584eb677f0af75fefe25d092670af9 Mon Sep 17 00:00:00 2001 From: ruzniaievdm Date: Wed, 10 Jan 2024 18:30:21 +0200 Subject: [PATCH] feat: [AXIMST-64] create API for group configurations (#2492) --- .../rest_api/v1/serializers/__init__.py | 11 ++- .../v1/serializers/group_configurations.py | 57 +++++++++++ .../contentstore/rest_api/v1/urls.py | 6 ++ .../rest_api/v1/views/__init__.py | 13 +-- .../rest_api/v1/views/group_configurations.py | 98 +++++++++++++++++++ .../views/tests/test_group_configurations.py | 58 +++++++++++ cms/djangoapps/contentstore/utils.py | 73 ++++++++++++++ cms/djangoapps/contentstore/views/course.py | 77 +++------------ 8 files changed, 314 insertions(+), 79 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/serializers/group_configurations.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index a05ffe7e6319..e8d3039b0b29 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -3,24 +3,25 @@ """ from .certificates import CourseCertificatesSerializer from .course_details import CourseDetailsSerializer +from .course_index import CourseIndexSerializer from .course_rerun import CourseRerunSerializer from .course_team import CourseTeamSerializer -from .course_index import CourseIndexSerializer from .grading import CourseGradingModelSerializer, CourseGradingSerializer +from .group_configurations import CourseGroupConfigurationsSerializer from .home import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer from .proctoring import ( LimitedProctoredExamSettingsSerializer, ProctoredExamConfigurationSerializer, ProctoredExamSettingsSerializer, - ProctoringErrorsSerializer + ProctoringErrorsSerializer, ) from .settings import CourseSettingsSerializer from .textbooks import CourseTextbooksSerializer +from .vertical_block import ContainerHandlerSerializer, VerticalContainerSerializer from .videos import ( CourseVideosSerializer, - VideoUploadSerializer, + VideoDownloadSerializer, VideoImageSerializer, + VideoUploadSerializer, VideoUsageSerializer, - VideoDownloadSerializer ) -from .vertical_block import ContainerHandlerSerializer, VerticalContainerSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/group_configurations.py new file mode 100644 index 000000000000..3ea2e205fb69 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/group_configurations.py @@ -0,0 +1,57 @@ +""" +API Serializers for course's settings group configurations. +""" + +from rest_framework import serializers + + +class GroupConfigurationUsageSerializer(serializers.Serializer): + """ + Serializer for representing nested usage inside configuration. + """ + + label = serializers.CharField() + url = serializers.CharField() + + +class GroupConfigurationGroupSerializer(serializers.Serializer): + """ + Serializer for representing nested group inside configuration. + """ + + id = serializers.IntegerField() + name = serializers.CharField() + usage = GroupConfigurationUsageSerializer(allow_null=True, many=True) + version = serializers.IntegerField() + + +class GroupConfigurationItemSerializer(serializers.Serializer): + """ + Serializer for representing group configurations item. + """ + + active = serializers.BooleanField() + description = serializers.CharField() + groups = GroupConfigurationGroupSerializer(allow_null=True, many=True) + id = serializers.IntegerField() + name = serializers.CharField() + parameters = serializers.DictField() + read_only = serializers.BooleanField(required=False) + scheme = serializers.CharField() + version = serializers.IntegerField() + + +class CourseGroupConfigurationsSerializer(serializers.Serializer): + """ + Serializer for representing course's settings group configurations. + """ + + all_group_configurations = GroupConfigurationItemSerializer(many=True) + experiment_group_configurations = GroupConfigurationItemSerializer( + allow_null=True, many=True + ) + mfe_proctored_exam_settings_url = serializers.CharField( + required=False, allow_null=True, allow_blank=True + ) + should_show_enrollment_track = serializers.BooleanField() + should_show_experiment_groups = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 000e2f2f3c0f..d04cdd88a973 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -13,6 +13,7 @@ CourseTextbooksView, CourseIndexView, CourseGradingView, + CourseGroupConfigurationsView, CourseRerunView, CourseSettingsView, CourseVideosView, @@ -115,6 +116,11 @@ CourseTextbooksView.as_view(), name="textbooks" ), + re_path( + fr'^group_configurations/{COURSE_ID_PATTERN}$', + CourseGroupConfigurationsView.as_view(), + name="group_configurations" + ), re_path( fr'^container_handler/{settings.USAGE_KEY_PATTERN}$', ContainerHandlerView.as_view(), diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index 804854184651..23de2d2107e7 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -4,17 +4,14 @@ from .certificates import CourseCertificatesView from .course_details import CourseDetailsView from .course_index import CourseIndexView -from .course_team import CourseTeamView from .course_rerun import CourseRerunView +from .course_team import CourseTeamView from .grading import CourseGradingView +from .group_configurations import CourseGroupConfigurationsView +from .help_urls import HelpUrlsView +from .home import HomePageCoursesView, HomePageLibrariesView, HomePageView from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView -from .home import HomePageView, HomePageCoursesView, HomePageLibrariesView from .settings import CourseSettingsView from .textbooks import CourseTextbooksView -from .videos import ( - CourseVideosView, - VideoUsageView, - VideoDownloadView -) -from .help_urls import HelpUrlsView from .vertical_block import ContainerHandlerView, VerticalContainerView +from .videos import CourseVideosView, VideoDownloadView, VideoUsageView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py new file mode 100644 index 000000000000..919f7800f43a --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py @@ -0,0 +1,98 @@ +""" API Views for course's settings group configurations """ + +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from cms.djangoapps.contentstore.utils import get_group_configurations_context +from cms.djangoapps.contentstore.rest_api.v1.serializers import ( + CourseGroupConfigurationsSerializer, +) +from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.lib.api.view_utils import ( + DeveloperErrorViewMixin, + verify_course_exists, + view_auth_classes, +) +from xmodule.modulestore.django import modulestore + + +@view_auth_classes(is_authenticated=True) +class CourseGroupConfigurationsView(DeveloperErrorViewMixin, APIView): + """ + View for course's settings group configurations. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ), + ], + responses={ + 200: CourseGroupConfigurationsSerializer, + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str): + """ + Get an object containing course's settings group configurations. + + **Example Request** + + GET /api/contentstore/v1/group_configurations/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's settings group configurations. + + **Example Response** + + ```json + { + "all_group_configurations": [ + { + "active": true, + "description": "The groups in this configuration can be mapped to cohorts in the Instructor.", + "groups": [ + { + "id": 593758473, + "name": "My Content Group", + "usage": [], + "version": 1 + } + ], + "id": 1791848226, + "name": "Content Groups", + "parameters": {}, + "read_only": false, + "scheme": "cohort", + "version": 3 + } + ], + "experiment_group_configurations": null, + "mfe_proctored_exam_settings_url": "", + "should_show_enrollment_track": false, + "should_show_experiment_groups": false + } + ``` + """ + course_key = CourseKey.from_string(course_id) + store = modulestore() + + if not has_studio_read_access(request.user, course_key): + self.permission_denied(request) + + with store.bulk_operations(course_key): + course = modulestore().get_course(course_key) + group_configurations_context = get_group_configurations_context(course, store) + serializer = CourseGroupConfigurationsSerializer(group_configurations_context) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py new file mode 100644 index 000000000000..4d7092841b8d --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py @@ -0,0 +1,58 @@ +""" +Unit tests for the course's setting group configuration. +""" +from django.urls import reverse +from rest_framework import status + +from cms.djangoapps.contentstore.course_group_config import ( + CONTENT_GROUP_CONFIGURATION_NAME, +) +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.views.tests.test_certificates import HelperMethods +from xmodule.partitions.partitions import ( + Group, + UserPartition, +) # lint-amnesty, pylint: disable=wrong-import-order + +from ...mixins import PermissionAccessMixin + + +class CourseGroupConfigurationsViewTest( + CourseTestCase, PermissionAccessMixin, HelperMethods +): + """ + Tests for CourseGroupConfigurationsView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:group_configurations", + kwargs={"course_id": self.course.id}, + ) + + def test_success_response(self): + """ + Check that endpoint is valid and success response. + """ + self.course.user_partitions = [ + UserPartition( + 0, + "First name", + "First description", + [Group(0, "Group A"), Group(1, "Group B"), Group(2, "Group C")], + ), # lint-amnesty, pylint: disable=line-too-long + ] + self.save_course() + + if "split_test" not in self.course.advanced_modules: + self.course.advanced_modules.append("split_test") + self.store.update_item(self.course, self.user.id) + + response = self.client.get(self.url) + self.assertEqual(len(response.data["all_group_configurations"]), 1) + self.assertEqual(len(response.data["experiment_group_configurations"]), 1) + self.assertContains(response, "First name", count=1) + self.assertContains(response, "Group C") + self.assertContains(response, CONTENT_GROUP_CONFIGURATION_NAME) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 33422f1c402b..83dcfb1864ae 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1974,6 +1974,79 @@ def get_textbooks_context(course): } +def get_group_configurations_context(course, store): + """ + Utils is used to get context for course's group configurations. + It is used for both DRF and django views. + """ + + from cms.djangoapps.contentstore.course_group_config import ( + COHORT_SCHEME, ENROLLMENT_SCHEME, GroupConfiguration, RANDOM_SCHEME + ) + from cms.djangoapps.contentstore.views.course import ( + are_content_experiments_enabled + ) + from xmodule.partitions.partitions import UserPartition # lint-amnesty, pylint: disable=wrong-import-order + + course_key = course.id + group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) + course_outline_url = reverse_course_url('course_handler', course_key) + should_show_experiment_groups = are_content_experiments_enabled(course) + if should_show_experiment_groups: + experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(store, course) + else: + experiment_group_configurations = None + + all_partitions = GroupConfiguration.get_all_user_partition_details(store, course) + should_show_enrollment_track = False + has_content_groups = False + displayable_partitions = [] + for partition in all_partitions: + partition['read_only'] = getattr(UserPartition.get_scheme(partition['scheme']), 'read_only', False) + + if partition['scheme'] == COHORT_SCHEME: + has_content_groups = True + displayable_partitions.append(partition) + elif partition['scheme'] == CONTENT_TYPE_GATING_SCHEME: + # Add it to the front of the list if it should be shown. + if ContentTypeGatingConfig.current(course_key=course_key).studio_override_enabled: + displayable_partitions.append(partition) + elif partition['scheme'] == ENROLLMENT_SCHEME: + should_show_enrollment_track = len(partition['groups']) > 1 + + # Add it to the front of the list if it should be shown. + if should_show_enrollment_track: + displayable_partitions.insert(0, partition) + elif partition['scheme'] != RANDOM_SCHEME: + # Experiment group configurations are handled explicitly above. We don't + # want to display their groups twice. + displayable_partitions.append(partition) + + # Set the sort-order. Higher numbers sort earlier + scheme_priority = defaultdict(lambda: -1, { + ENROLLMENT_SCHEME: 1, + CONTENT_TYPE_GATING_SCHEME: 0 + }) + displayable_partitions.sort(key=lambda p: scheme_priority[p['scheme']], reverse=True) + # Add empty content group if there is no COHORT User Partition in the list. + # This will add ability to add new groups in the view. + if not has_content_groups: + displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course)) + + context = { + 'context_course': course, + 'group_configuration_url': group_configuration_url, + 'course_outline_url': course_outline_url, + 'experiment_group_configurations': experiment_group_configurations, + 'should_show_experiment_groups': should_show_experiment_groups, + 'all_group_configurations': displayable_partitions, + 'should_show_enrollment_track': should_show_enrollment_track, + 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course.id), + } + + return context + + class StudioPermissionsService: """ Service that can provide information about a user's permissions. diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index a58c337b46f6..98d5451edaba 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -7,7 +7,6 @@ import random import re import string -from collections import defaultdict from typing import Dict import django.utils @@ -62,8 +61,6 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.lib.course_tabs import CourseTabPluginManager -from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME from organizations.models import Organization from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order from xmodule.course_block import CourseBlock, CourseFields # lint-amnesty, pylint: disable=wrong-import-order @@ -71,12 +68,10 @@ from xmodule.modulestore import EdxJSONEncoder # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import DuplicateCourseError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions import UserPartition # lint-amnesty, pylint: disable=wrong-import-order from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException # lint-amnesty, pylint: disable=wrong-import-order from ..course_group_config import ( COHORT_SCHEME, - ENROLLMENT_SCHEME, RANDOM_SCHEME, GroupConfiguration, GroupConfigurationsValidationError @@ -95,21 +90,22 @@ ) from ..utils import ( add_instructor, - get_course_settings, + get_advanced_settings_url, get_course_grading, + get_course_index_context, + get_course_outline_url, + get_course_rerun_context, + get_course_settings, + get_grading_url, + get_group_configurations_context, get_home_context, get_library_context, - get_course_index_context, get_lms_link_for_item, get_proctored_exam_settings_url, - get_course_outline_url, - get_studio_home_url, - get_updates_url, - get_advanced_settings_url, - get_grading_url, get_schedule_details_url, - get_course_rerun_context, + get_studio_home_url, get_textbooks_context, + get_updates_url, initialize_permissions, remove_all_instructors, reverse_course_url, @@ -1513,59 +1509,8 @@ def group_configurations_list_handler(request, course_key_string): course = get_course_and_check_access(course_key, request.user) if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): - group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) - course_outline_url = reverse_course_url('course_handler', course_key) - should_show_experiment_groups = are_content_experiments_enabled(course) - if should_show_experiment_groups: - experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(store, course) - else: - experiment_group_configurations = None - - all_partitions = GroupConfiguration.get_all_user_partition_details(store, course) - should_show_enrollment_track = False - has_content_groups = False - displayable_partitions = [] - for partition in all_partitions: - partition['read_only'] = getattr(UserPartition.get_scheme(partition['scheme']), 'read_only', False) - - if partition['scheme'] == COHORT_SCHEME: - has_content_groups = True - displayable_partitions.append(partition) - elif partition['scheme'] == CONTENT_TYPE_GATING_SCHEME: - # Add it to the front of the list if it should be shown. - if ContentTypeGatingConfig.current(course_key=course_key).studio_override_enabled: - displayable_partitions.append(partition) - elif partition['scheme'] == ENROLLMENT_SCHEME: - should_show_enrollment_track = len(partition['groups']) > 1 - - # Add it to the front of the list if it should be shown. - if should_show_enrollment_track: - displayable_partitions.insert(0, partition) - elif partition['scheme'] != RANDOM_SCHEME: - # Experiment group configurations are handled explicitly above. We don't - # want to display their groups twice. - displayable_partitions.append(partition) - - # Set the sort-order. Higher numbers sort earlier - scheme_priority = defaultdict(lambda: -1, { - ENROLLMENT_SCHEME: 1, - CONTENT_TYPE_GATING_SCHEME: 0 - }) - displayable_partitions.sort(key=lambda p: scheme_priority[p['scheme']], reverse=True) - # Add empty content group if there is no COHORT User Partition in the list. - # This will add ability to add new groups in the view. - if not has_content_groups: - displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course)) - return render_to_response('group_configurations.html', { - 'context_course': course, - 'group_configuration_url': group_configuration_url, - 'course_outline_url': course_outline_url, - 'experiment_group_configurations': experiment_group_configurations, - 'should_show_experiment_groups': should_show_experiment_groups, - 'all_group_configurations': displayable_partitions, - 'should_show_enrollment_track': should_show_enrollment_track, - 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course.id), - }) + group_configurations_context = get_group_configurations_context(course, store) + return render_to_response('group_configurations.html', group_configurations_context) elif "application/json" in request.META.get('HTTP_ACCEPT'): if request.method == 'POST': # create a new group configuration for the course