feat: create DRF endpoint to get course index context (#33667)

* feat: create DRF endpoint to get course index context

* refactor: update serializers location and added some tests

* refactor: move modulestore usage out of views

---------

Co-authored-by: ruzniaievdm <ruzniaievdm@gmail.com>
This commit is contained in:
Navin Karkera
2023-12-14 20:24:20 +05:30
committed by GitHub
parent 93099c384d
commit aaea6e5b14
10 changed files with 381 additions and 92 deletions

View File

@@ -50,3 +50,23 @@ class StrictSerializer(serializers.Serializer):
)
return ret
class ProctoringErrorModelSerializer(serializers.Serializer):
"""
Serializer for proctoring error model item.
"""
deprecated = serializers.BooleanField()
display_name = serializers.CharField()
help = serializers.CharField()
hide_on_enabled_publisher = serializers.BooleanField()
value = serializers.CharField()
class ProctoringErrorListSerializer(serializers.Serializer):
"""
Serializer for proctoring error list.
"""
key = serializers.CharField()
message = serializers.CharField()
model = ProctoringErrorModelSerializer()

View File

@@ -4,6 +4,7 @@ Serializers for v1 contentstore API.
from .course_details import CourseDetailsSerializer
from .course_rerun import CourseRerunSerializer
from .course_team import CourseTeamSerializer
from .course_index import CourseIndexSerializer
from .grading import CourseGradingModelSerializer, CourseGradingSerializer
from .home import CourseHomeSerializer
from .proctoring import (

View File

@@ -0,0 +1,31 @@
"""
API Serializers for course index
"""
from rest_framework import serializers
from cms.djangoapps.contentstore.rest_api.serializers.common import ProctoringErrorListSerializer
class InitialIndexStateSerializer(serializers.Serializer):
"""Serializer for initial course index state"""
expanded_locators = serializers.ListSerializer(child=serializers.CharField())
locator_to_show = serializers.CharField()
class CourseIndexSerializer(serializers.Serializer):
"""Serializer for course index"""
course_release_date = serializers.CharField()
course_structure = serializers.DictField()
deprecated_blocks_info = serializers.DictField()
discussions_incontext_feedback_url = serializers.CharField()
discussions_incontext_learnmore_url = serializers.CharField()
initial_state = InitialIndexStateSerializer()
initial_user_clipboard = serializers.DictField()
language_code = serializers.CharField()
lms_link = serializers.CharField()
mfe_proctored_exam_settings_url = serializers.CharField()
notification_dismiss_url = serializers.CharField()
proctoring_errors = ProctoringErrorListSerializer(many=True)
reindex_link = serializers.CharField()
rerun_notification_id = serializers.IntegerField()

View File

@@ -4,6 +4,7 @@ API Serializers for proctoring
from rest_framework import serializers
from cms.djangoapps.contentstore.rest_api.serializers.common import ProctoringErrorListSerializer
from xmodule.course_block import get_available_providers
@@ -31,26 +32,6 @@ class ProctoredExamConfigurationSerializer(serializers.Serializer):
course_start_date = serializers.DateTimeField()
class ProctoringErrorModelSerializer(serializers.Serializer):
"""
Serializer for proctoring error model item.
"""
deprecated = serializers.BooleanField()
display_name = serializers.CharField()
help = serializers.CharField()
hide_on_enabled_publisher = serializers.BooleanField()
value = serializers.CharField()
class ProctoringErrorListSerializer(serializers.Serializer):
"""
Serializer for proctoring error list.
"""
key = serializers.CharField()
message = serializers.CharField()
model = ProctoringErrorModelSerializer()
class ProctoringErrorsSerializer(serializers.Serializer):
"""
Serializer for proctoring errors with url to proctored exam settings.

View File

@@ -7,6 +7,7 @@ from openedx.core.constants import COURSE_ID_PATTERN
from .views import (
CourseDetailsView,
CourseTeamView,
CourseIndexView,
CourseGradingView,
CourseRerunView,
CourseSettingsView,
@@ -59,6 +60,11 @@ urlpatterns = [
CourseSettingsView.as_view(),
name="course_settings"
),
re_path(
fr'^course_index/{COURSE_ID_PATTERN}$',
CourseIndexView.as_view(),
name="course_index"
),
re_path(
fr'^course_details/{COURSE_ID_PATTERN}$',
CourseDetailsView.as_view(),

View File

@@ -2,6 +2,7 @@
Views for v1 contentstore API.
"""
from .course_details import CourseDetailsView
from .course_index import CourseIndexView
from .course_team import CourseTeamView
from .course_rerun import CourseRerunView
from .grading import CourseGradingView

View File

@@ -0,0 +1,98 @@
"""API Views for course index"""
import edx_api_doc_tools as apidocs
from django.conf import settings
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.rest_api.v1.serializers import CourseIndexSerializer
from cms.djangoapps.contentstore.utils import get_course_index_context
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
@view_auth_classes(is_authenticated=True)
class CourseIndexView(DeveloperErrorViewMixin, APIView):
"""View for Course Index"""
@apidocs.schema(
parameters=[
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
apidocs.string_parameter(
"show",
apidocs.ParameterLocation.QUERY,
description="Query param to set initial state which fully expanded to see the item",
)],
responses={
200: CourseIndexSerializer,
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 index for outline.
**Example Request**
GET /api/contentstore/v1/course_index/{course_id}?show=block-v1:edx+101+y+type@course+block@course
**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 outline.
**Example Response**
```json
{
"course_release_date": "Set Date",
"course_structure": {},
"deprecated_blocks_info": {
"deprecated_enabled_block_types": [],
"blocks": [],
"advance_settings_url": "/settings/advanced/course-v1:edx+101+y76"
},
"discussions_incontext_feedback_url": "",
"discussions_incontext_learnmore_url": "",
"initial_state": {
"expanded_locators": [
"block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6",
"block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d"
],
"locator_to_show": "block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6"
},
"initial_user_clipboard": {
"content": null,
"source_usage_key": "",
"source_context_title": "",
"source_edit_url": ""
},
"language_code": "en",
"lms_link": "//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76",
"mfe_proctored_exam_settings_url": "",
"notification_dismiss_url": "/course_notifications/course-v1:edx+101+y76/2",
"proctoring_errors": [],
"reindex_link": "/course/course-v1:edx+101+y76/search_reindex",
"rerun_notification_id": 2
}
```
"""
course_key = CourseKey.from_string(course_id)
if not has_studio_read_access(request.user, course_key):
self.permission_denied(request)
course_index_context = get_course_index_context(request, course_key)
course_index_context.update({
"discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL,
"discussions_incontext_feedback_url": settings.DISCUSSIONS_INCONTEXT_FEEDBACK_URL,
})
serializer = CourseIndexSerializer(course_index_context)
return Response(serializer.data)

View File

@@ -0,0 +1,125 @@
"""
Unit tests for course index outline.
"""
from django.test import RequestFactory
from django.urls import reverse
from rest_framework import status
from cms.djangoapps.contentstore.rest_api.v1.mixins import PermissionAccessMixin
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.utils import get_lms_link_for_item
from cms.djangoapps.contentstore.views.course import _course_outline_json
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import BlockFactory
class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin):
"""
Tests for CourseIndexView.
"""
def setUp(self):
super().setUp()
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.chapter = BlockFactory.create(
parent=self.course, display_name='Overview'
)
self.section = BlockFactory.create(
parent=self.chapter, display_name='Welcome'
)
self.unit = BlockFactory.create(
parent=self.section, display_name='New Unit'
)
self.xblock = BlockFactory.create(
parent=self.unit,
category='problem',
display_name='Some problem'
)
self.user = UserFactory()
self.factory = RequestFactory()
self.request = self.factory.get(f"/course/{self.course.id}")
self.request.user = self.user
self.reload_course()
self.url = reverse(
"cms.djangoapps.contentstore:v1:course_index",
kwargs={"course_id": self.course.id},
)
def test_course_index_response(self):
"""Check successful response content"""
response = self.client.get(self.url)
expected_response = {
"course_release_date": "Set Date",
"course_structure": _course_outline_json(self.request, self.course),
"deprecated_blocks_info": {
"deprecated_enabled_block_types": [],
"blocks": [],
"advance_settings_url": f"/settings/advanced/{self.course.id}"
},
"discussions_incontext_feedback_url": "",
"discussions_incontext_learnmore_url": "",
"initial_state": None,
"initial_user_clipboard": {
"content": None,
"source_usage_key": "",
"source_context_title": "",
"source_edit_url": ""
},
"language_code": "en",
"lms_link": get_lms_link_for_item(self.course.location),
"mfe_proctored_exam_settings_url": "",
"notification_dismiss_url": None,
"proctoring_errors": [],
"reindex_link": f"/course/{self.course.id}/search_reindex",
"rerun_notification_id": None
}
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictEqual(expected_response, response.data)
def test_course_index_response_with_show_locators(self):
"""Check successful response content with show query param"""
response = self.client.get(self.url, {"show": str(self.unit.location)})
expected_response = {
"course_release_date": "Set Date",
"course_structure": _course_outline_json(self.request, self.course),
"deprecated_blocks_info": {
"deprecated_enabled_block_types": [],
"blocks": [],
"advance_settings_url": f"/settings/advanced/{self.course.id}"
},
"discussions_incontext_feedback_url": "",
"discussions_incontext_learnmore_url": "",
"initial_state": {
"expanded_locators": [
str(self.unit.location),
str(self.xblock.location),
],
"locator_to_show": str(self.unit.location),
},
"initial_user_clipboard": {
"content": None,
"source_usage_key": "",
"source_context_title": "",
"source_edit_url": ""
},
"language_code": "en",
"lms_link": get_lms_link_for_item(self.course.location),
"mfe_proctored_exam_settings_url": "",
"notification_dismiss_url": None,
"proctoring_errors": [],
"reindex_link": f"/course/{self.course.id}/search_reindex",
"rerun_notification_id": None
}
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictEqual(expected_response, response.data)
def test_course_index_response_with_invalid_course(self):
"""Check error response for invalid course id"""
response = self.client.get(self.url + "1")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data, {
"developer_message": f"Unknown course {self.course.id}1",
"error_code": "course_does_not_exist"
})

View File

@@ -25,7 +25,8 @@ from pytz import UTC
from xblock.fields import Scope
from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled
from common.djangoapps.course_action_state.models import CourseRerunUIStateManager
from common.djangoapps.course_action_state.models import CourseRerunUIStateManager, CourseRerunState
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.edxmako.services import MakoService
from common.djangoapps.student import auth
@@ -45,6 +46,8 @@ from common.djangoapps.util.milestones_helpers import (
get_namespace_choices,
generate_milestone_namespace
)
from common.djangoapps.util.date_utils import get_default_time_display
from common.djangoapps.xblock_django.api import deprecated_xblocks
from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
from openedx.core import toggles as core_toggles
from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course
@@ -80,7 +83,9 @@ from cms.djangoapps.contentstore.toggles import (
# use_xpert_translations_component,
)
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from xmodule.library_tools import LibraryToolsService
from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore import ModuleStoreEnum # 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 ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
@@ -1672,6 +1677,89 @@ def get_course_videos_context(course_block, pagination_conf, course_key=None):
return course_video_context
def get_course_index_context(request, course_key, course_block=None):
"""
Utils is used to get context of course index outline.
It is used for both DRF and django views.
"""
from cms.djangoapps.contentstore.views.course import (
course_outline_initial_state,
_course_outline_json,
_deprecated_blocks_info,
)
from openedx.core.djangoapps.content_staging import api as content_staging_api
if not course_block:
with modulestore().bulk_operations(course_key):
course_block = modulestore().get_course(course_key)
lms_link = get_lms_link_for_item(course_block.location)
reindex_link = None
if settings.FEATURES.get('ENABLE_COURSEWARE_INDEX', False):
if GlobalStaff().has_user(request.user):
reindex_link = f"/course/{str(course_key)}/search_reindex"
sections = course_block.get_children()
course_structure = _course_outline_json(request, course_block)
locator_to_show = request.GET.get('show', None)
course_release_date = (
get_default_time_display(course_block.start)
if course_block.start != DEFAULT_START_DATE
else _("Set Date")
)
settings_url = reverse_course_url('settings_handler', course_key)
try:
current_action = CourseRerunState.objects.find_first(course_key=course_key, should_display=True)
except (ItemNotFoundError, CourseActionStateItemNotFoundError):
current_action = None
deprecated_block_names = [block.name for block in deprecated_xblocks()]
deprecated_blocks_info = _deprecated_blocks_info(course_block, deprecated_block_names)
frontend_app_publisher_url = configuration_helpers.get_value_for_org(
course_block.location.org,
'FRONTEND_APP_PUBLISHER_URL',
settings.FEATURES.get('FRONTEND_APP_PUBLISHER_URL', False)
)
# gather any errors in the currently stored proctoring settings.
advanced_dict = CourseMetadata.fetch(course_block)
proctoring_errors = CourseMetadata.validate_proctoring_settings(course_block, advanced_dict, request.user)
user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request)
course_index_context = {
'language_code': request.LANGUAGE_CODE,
'context_course': course_block,
'lms_link': lms_link,
'sections': sections,
'course_structure': course_structure,
'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, # lint-amnesty, pylint: disable=line-too-long
'initial_user_clipboard': user_clipboard,
'rerun_notification_id': current_action.id if current_action else None,
'course_release_date': course_release_date,
'settings_url': settings_url,
'reindex_link': reindex_link,
'deprecated_blocks_info': deprecated_blocks_info,
'notification_dismiss_url': reverse_course_url(
'course_notifications_handler',
current_action.course_key,
kwargs={
'action_state_id': current_action.id,
},
) if current_action else None,
'frontend_app_publisher_url': frontend_app_publisher_url,
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id),
'advance_settings_url': reverse_course_url('advanced_settings_handler', course_block.id),
'proctoring_errors': proctoring_errors,
'taxonomy_tags_widget_url': get_taxonomy_tags_widget_url(course_block.id),
}
return course_index_context
class StudioPermissionsService:
"""
Service that can provide information about a user's permissions.

View File

@@ -54,12 +54,9 @@ from common.djangoapps.student.roles import (
UserBasedRole,
OrgStaffRole
)
from common.djangoapps.util.date_utils import get_default_time_display
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
from common.djangoapps.util.string_utils import _has_non_ascii_characters
from common.djangoapps.xblock_django.api import deprecated_xblocks
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content_staging import api as content_staging_api
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
@@ -69,11 +66,11 @@ 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, DEFAULT_START_DATE, CourseFields # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.course_block import CourseBlock, CourseFields # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.error_block import ErrorBlock # lint-amnesty, pylint: disable=wrong-import-order
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, ItemNotFoundError # 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
@@ -102,10 +99,10 @@ from ..utils import (
get_course_grading,
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_taxonomy_tags_widget_url,
get_studio_home_url,
get_updates_url,
get_advanced_settings_url,
@@ -625,72 +622,13 @@ def course_index(request, course_key):
# A unit may not have a draft version, but one of its components could, and hence the unit itself has changes.
with modulestore().bulk_operations(course_key):
course_block = get_course_and_check_access(course_key, request.user, depth=None)
if not course_block:
raise Http404
if use_new_course_outline_page(course_key):
return redirect(get_course_outline_url(course_key))
lms_link = get_lms_link_for_item(course_block.location)
reindex_link = None
if settings.FEATURES.get('ENABLE_COURSEWARE_INDEX', False):
if GlobalStaff().has_user(request.user):
reindex_link = f"/course/{str(course_key)}/search_reindex"
sections = course_block.get_children()
course_structure = _course_outline_json(request, course_block)
locator_to_show = request.GET.get('show', None)
if not course_block:
raise Http404
if use_new_course_outline_page(course_key):
return redirect(get_course_outline_url(course_key))
course_release_date = (
get_default_time_display(course_block.start)
if course_block.start != DEFAULT_START_DATE
else _("Set Date")
)
settings_url = reverse_course_url('settings_handler', course_key)
try:
current_action = CourseRerunState.objects.find_first(course_key=course_key, should_display=True)
except (ItemNotFoundError, CourseActionStateItemNotFoundError):
current_action = None
deprecated_block_names = [block.name for block in deprecated_xblocks()]
deprecated_blocks_info = _deprecated_blocks_info(course_block, deprecated_block_names)
frontend_app_publisher_url = configuration_helpers.get_value_for_org(
course_block.location.org,
'FRONTEND_APP_PUBLISHER_URL',
settings.FEATURES.get('FRONTEND_APP_PUBLISHER_URL', False)
)
# gather any errors in the currently stored proctoring settings.
advanced_dict = CourseMetadata.fetch(course_block)
proctoring_errors = CourseMetadata.validate_proctoring_settings(course_block, advanced_dict, request.user)
user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request)
return render_to_response('course_outline.html', {
'language_code': request.LANGUAGE_CODE,
'context_course': course_block,
'lms_link': lms_link,
'sections': sections,
'course_structure': course_structure,
'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, # lint-amnesty, pylint: disable=line-too-long
'initial_user_clipboard': user_clipboard,
'rerun_notification_id': current_action.id if current_action else None,
'course_release_date': course_release_date,
'settings_url': settings_url,
'reindex_link': reindex_link,
'deprecated_blocks_info': deprecated_blocks_info,
'notification_dismiss_url': reverse_course_url(
'course_notifications_handler',
current_action.course_key,
kwargs={
'action_state_id': current_action.id,
},
) if current_action else None,
'frontend_app_publisher_url': frontend_app_publisher_url,
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id),
'advance_settings_url': reverse_course_url('advanced_settings_handler', course_block.id),
'proctoring_errors': proctoring_errors,
'taxonomy_tags_widget_url': get_taxonomy_tags_widget_url(course_block.id),
})
course_index_context = get_course_index_context(request, course_key, course_block)
return render_to_response('course_outline.html', course_index_context)
@function_trace('get_courses_accessible_to_user')