feat: [FC-0044] XBlock's children API as DRF (#34055)
* feat: XBlock's children API as DRF * fix: 500 error appears if user adds a Content Experiment * fix: wrap into try/except block getting icon for xblock (#2509) * fix: wrap into try/except block getting icon for xblock * fix: revision after review
This commit is contained in:
@@ -2,10 +2,16 @@
|
||||
Common mixins for module.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.http import Http404
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from rest_framework import status
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PermissionAccessMixin:
|
||||
"""
|
||||
@@ -30,7 +36,7 @@ class PermissionAccessMixin:
|
||||
self.assertEqual(error, "Authentication credentials were not provided.")
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_ADVANCED_SETTINGS': True})
|
||||
@patch.dict("django.conf.settings.FEATURES", {"DISABLE_ADVANCED_SETTINGS": True})
|
||||
def test_permissions_unauthorized(self):
|
||||
"""
|
||||
Test that an error is returned if the user is unauthorised.
|
||||
@@ -40,3 +46,20 @@ class PermissionAccessMixin:
|
||||
error = self.get_and_check_developer_response(response)
|
||||
self.assertEqual(error, "You do not have permission to perform this action.")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
class ContainerHandlerMixin:
|
||||
"""
|
||||
A mixin providing common functionality for container handler views.
|
||||
"""
|
||||
|
||||
def get_object(self, usage_key_string):
|
||||
"""
|
||||
Get an object by usage-id of the block
|
||||
"""
|
||||
try:
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
return usage_key
|
||||
except InvalidKeyError as err:
|
||||
log.error(f"Invalid usage key: {usage_key_string}", exc_info=True)
|
||||
raise Http404(f"Object not found for usage key: {usage_key_string}") from err
|
||||
|
||||
@@ -21,4 +21,4 @@ from .videos import (
|
||||
VideoUsageSerializer,
|
||||
VideoDownloadSerializer
|
||||
)
|
||||
from .vertical_block import ContainerHandlerSerializer
|
||||
from .vertical_block import ContainerHandlerSerializer, VerticalContainerSerializer
|
||||
|
||||
@@ -91,3 +91,21 @@ class ContainerHandlerSerializer(serializers.Serializer):
|
||||
"assets_handler", kwargs={"course_key_string": context_course.id}
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class ChildVerticalContainerSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for representing a xblock child of vertical container.
|
||||
"""
|
||||
|
||||
name = serializers.CharField(source="display_name_with_default")
|
||||
block_id = serializers.CharField(source="location")
|
||||
|
||||
|
||||
class VerticalContainerSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for representing a vertical container with state and children.
|
||||
"""
|
||||
|
||||
children = ChildVerticalContainerSerializer(many=True)
|
||||
is_published = serializers.BooleanField()
|
||||
|
||||
@@ -21,7 +21,8 @@ from .views import (
|
||||
ProctoringErrorsView,
|
||||
HelpUrlsView,
|
||||
VideoUsageView,
|
||||
VideoDownloadView
|
||||
VideoDownloadView,
|
||||
VerticalContainerView,
|
||||
)
|
||||
|
||||
app_name = 'v1'
|
||||
@@ -107,6 +108,11 @@ urlpatterns = [
|
||||
ContainerHandlerView.as_view(),
|
||||
name="container_handler"
|
||||
),
|
||||
re_path(
|
||||
fr'^container/vertical/{settings.USAGE_KEY_PATTERN}/children$',
|
||||
VerticalContainerView.as_view(),
|
||||
name="container_vertical"
|
||||
),
|
||||
|
||||
# Authoring API
|
||||
# Do not use under v1 yet (Nov. 23). The Authoring API is still experimental and the v0 versions should be used
|
||||
|
||||
@@ -15,4 +15,4 @@ from .videos import (
|
||||
VideoDownloadView
|
||||
)
|
||||
from .help_urls import HelpUrlsView
|
||||
from .vertical_block import ContainerHandlerView
|
||||
from .vertical_block import ContainerHandlerView, VerticalContainerView
|
||||
|
||||
@@ -5,55 +5,107 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import (
|
||||
modulestore,
|
||||
) # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import (
|
||||
BlockFactory,
|
||||
) # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore import (
|
||||
ModuleStoreEnum,
|
||||
) # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
|
||||
class ContainerHandlerViewTest(CourseTestCase):
|
||||
class BaseXBlockContainer(CourseTestCase):
|
||||
"""
|
||||
Unit tests for the ContainerHandlerView.
|
||||
Base xBlock container handler.
|
||||
|
||||
Contains common function for processing course xblocks.
|
||||
"""
|
||||
|
||||
view_name = None
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.chapter = BlockFactory.create(
|
||||
parent=self.course, category="chapter", display_name="Week 1"
|
||||
)
|
||||
self.sequential = BlockFactory.create(
|
||||
parent=self.chapter, category="sequential", display_name="Lesson 1"
|
||||
)
|
||||
self.vertical = self._create_block(self.sequential, "vertical", "Unit")
|
||||
|
||||
self.store = modulestore()
|
||||
self.store.publish(self.vertical.location, self.user.id)
|
||||
self.setup_xblock()
|
||||
|
||||
def _get_reverse_url(self, location):
|
||||
def setup_xblock(self):
|
||||
"""
|
||||
Creates url to current handler view api
|
||||
Set up XBlock objects for testing purposes.
|
||||
|
||||
This method creates XBlock objects representing a course structure with chapters,
|
||||
sequentials, verticals and others.
|
||||
"""
|
||||
return reverse(
|
||||
"cms.djangoapps.contentstore:v1:container_handler",
|
||||
kwargs={"usage_key_string": location},
|
||||
self.chapter = self.create_block(
|
||||
parent=self.course.location,
|
||||
category="chapter",
|
||||
display_name="Week 1",
|
||||
)
|
||||
|
||||
def _create_block(self, parent, category, display_name, **kwargs):
|
||||
self.sequential = self.create_block(
|
||||
parent=self.chapter.location,
|
||||
category="sequential",
|
||||
display_name="Lesson 1",
|
||||
)
|
||||
|
||||
self.vertical = self.create_block(self.sequential.location, "vertical", "Unit")
|
||||
|
||||
self.html_unit_first = self.create_block(
|
||||
parent=self.vertical.location,
|
||||
category="html",
|
||||
display_name="Html Content 1",
|
||||
)
|
||||
|
||||
self.html_unit_second = self.create_block(
|
||||
parent=self.vertical.location,
|
||||
category="html",
|
||||
display_name="Html Content 2",
|
||||
)
|
||||
|
||||
def create_block(self, parent, category, display_name, **kwargs):
|
||||
"""
|
||||
Creates a block without publishing it.
|
||||
"""
|
||||
return BlockFactory.create(
|
||||
parent=parent,
|
||||
parent_location=parent,
|
||||
category=category,
|
||||
display_name=display_name,
|
||||
modulestore=self.store,
|
||||
publish_item=False,
|
||||
user_id=self.user.id,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_reverse_url(self, location):
|
||||
"""
|
||||
Creates url to current view api name
|
||||
"""
|
||||
return reverse(
|
||||
f"cms.djangoapps.contentstore:v1:{self.view_name}",
|
||||
kwargs={"usage_key_string": location},
|
||||
)
|
||||
|
||||
def publish_item(self, store, item_location):
|
||||
"""
|
||||
Publish the item at the given location
|
||||
"""
|
||||
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
||||
store.publish(item_location, ModuleStoreEnum.UserID.test)
|
||||
|
||||
|
||||
class ContainerHandlerViewTest(BaseXBlockContainer):
|
||||
"""
|
||||
Unit tests for the ContainerHandlerView.
|
||||
"""
|
||||
|
||||
view_name = "container_handler"
|
||||
|
||||
def test_success_response(self):
|
||||
"""
|
||||
Check that endpoint is valid and success response.
|
||||
"""
|
||||
url = self._get_reverse_url(self.vertical.location)
|
||||
url = self.get_reverse_url(self.vertical.location)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -61,7 +113,66 @@ class ContainerHandlerViewTest(CourseTestCase):
|
||||
"""
|
||||
Check that invalid 'usage_key_string' raises Http404.
|
||||
"""
|
||||
usage_key_string = "i4x://InvalidOrg/InvalidCourse/vertical/static/InvalidContent"
|
||||
url = self._get_reverse_url(usage_key_string)
|
||||
usage_key_string = (
|
||||
"i4x://InvalidOrg/InvalidCourse/vertical/static/InvalidContent"
|
||||
)
|
||||
url = self.get_reverse_url(usage_key_string)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class ContainerVerticalViewTest(BaseXBlockContainer):
|
||||
"""
|
||||
Unit tests for the ContainerVerticalViewTest.
|
||||
"""
|
||||
|
||||
view_name = "container_vertical"
|
||||
|
||||
def test_success_response(self):
|
||||
"""
|
||||
Check that endpoint returns valid response data.
|
||||
"""
|
||||
url = self.get_reverse_url(self.vertical.location)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["children"]), 2)
|
||||
self.assertFalse(response.data["is_published"])
|
||||
|
||||
def test_xblock_is_published(self):
|
||||
"""
|
||||
Check that published xBlock container returns.
|
||||
"""
|
||||
self.publish_item(self.store, self.vertical.location)
|
||||
url = self.get_reverse_url(self.vertical.location)
|
||||
response = self.client.get(url)
|
||||
self.assertTrue(response.data["is_published"])
|
||||
|
||||
def test_children_content(self):
|
||||
"""
|
||||
Check that returns valid response with children of vertical container.
|
||||
"""
|
||||
url = self.get_reverse_url(self.vertical.location)
|
||||
response = self.client.get(url)
|
||||
|
||||
expected_response = [
|
||||
{
|
||||
"name": self.html_unit_first.display_name_with_default,
|
||||
"block_id": str(self.html_unit_first.location),
|
||||
},
|
||||
{
|
||||
"name": self.html_unit_second.display_name_with_default,
|
||||
"block_id": str(self.html_unit_second.location),
|
||||
},
|
||||
]
|
||||
self.assertEqual(response.data["children"], expected_response)
|
||||
|
||||
def test_not_valid_usage_key_string(self):
|
||||
"""
|
||||
Check that invalid 'usage_key_string' raises Http404.
|
||||
"""
|
||||
usage_key_string = (
|
||||
"i4x://InvalidOrg/InvalidCourse/vertical/static/InvalidContent"
|
||||
)
|
||||
url = self.get_reverse_url(usage_key_string)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@@ -1,37 +1,31 @@
|
||||
""" API Views for unit page """
|
||||
|
||||
import edx_api_doc_tools as apidocs
|
||||
from django.http import Http404, HttpResponseBadRequest
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from django.http import HttpResponseBadRequest
|
||||
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_container_handler_context
|
||||
from cms.djangoapps.contentstore.views.component import _get_item_in_course
|
||||
from cms.djangoapps.contentstore.rest_api.v1.serializers import ContainerHandlerSerializer
|
||||
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock
|
||||
from cms.djangoapps.contentstore.rest_api.v1.serializers import (
|
||||
ContainerHandlerSerializer,
|
||||
VerticalContainerSerializer,
|
||||
)
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
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
|
||||
|
||||
from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class ContainerHandlerView(APIView):
|
||||
class ContainerHandlerView(APIView, ContainerHandlerMixin):
|
||||
"""
|
||||
View for container xblock requests to get vertical data.
|
||||
"""
|
||||
|
||||
def get_object(self, usage_key_string):
|
||||
"""
|
||||
Get an object by usage-id of the block
|
||||
"""
|
||||
try:
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
except InvalidKeyError:
|
||||
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
||||
return usage_key
|
||||
|
||||
@apidocs.schema(
|
||||
parameters=[
|
||||
apidocs.string_parameter(
|
||||
@@ -146,3 +140,73 @@ class ContainerHandlerView(APIView):
|
||||
})
|
||||
serializer = ContainerHandlerSerializer(context)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class VerticalContainerView(APIView, ContainerHandlerMixin):
|
||||
"""
|
||||
View for container xblock requests to get vertical state and children data.
|
||||
"""
|
||||
|
||||
@apidocs.schema(
|
||||
parameters=[
|
||||
apidocs.string_parameter(
|
||||
"usage_key_string",
|
||||
apidocs.ParameterLocation.PATH,
|
||||
description="Vertical usage key",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: VerticalContainerSerializer,
|
||||
401: "The requester is not authenticated.",
|
||||
404: "The requested locator does not exist.",
|
||||
},
|
||||
)
|
||||
def get(self, request: Request, usage_key_string: str):
|
||||
"""
|
||||
Get an object containing vertical state with children data.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/contentstore/v1/container/vertical/{usage_key_string}/children
|
||||
|
||||
**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 vertical's container children data.
|
||||
|
||||
**Example Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"name": "Drag and Drop",
|
||||
"block_id": "block-v1:org+101+101+type@drag-and-drop-v2+block@7599275ace6b46f5a482078a2954ca16"
|
||||
},
|
||||
{
|
||||
"name": "Video",
|
||||
"block_id": "block-v1:org+101+101+type@video+block@0e3d39b12d7c4345981bda6b3511a9bf"
|
||||
},
|
||||
{
|
||||
"name": "Text",
|
||||
"block_id": "block-v1:org+101+101+type@html+block@3e3fa1f88adb4a108cd14e9002143690"
|
||||
}
|
||||
],
|
||||
"is_published": false
|
||||
}
|
||||
```
|
||||
"""
|
||||
usage_key = self.get_object(usage_key_string)
|
||||
current_xblock = get_xblock(usage_key, request.user)
|
||||
|
||||
with modulestore().bulk_operations(usage_key.course_key):
|
||||
children = [
|
||||
modulestore().get_item(child) for child in current_xblock.children
|
||||
]
|
||||
is_published = not modulestore().has_changes(current_xblock)
|
||||
container_data = {"children": children, "is_published": is_published}
|
||||
serializer = VerticalContainerSerializer(container_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -48,6 +48,7 @@ from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
|
||||
from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE
|
||||
from openedx.core.lib.gating import api as gating_api
|
||||
from openedx.core.lib.cache_utils import request_cached
|
||||
from openedx.core.lib.xblock_utils import get_icon
|
||||
from openedx.core.toggles import ENTRANCE_EXAMS
|
||||
from xmodule.course_block import DEFAULT_START_DATE
|
||||
from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum
|
||||
@@ -1095,6 +1096,7 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements
|
||||
"show_correctness": xblock.show_correctness,
|
||||
"hide_from_toc": xblock.hide_from_toc,
|
||||
"enable_hide_from_toc_ui": settings.FEATURES.get("ENABLE_HIDE_FROM_TOC_UI", False),
|
||||
"xblock_type": get_icon(xblock),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from lxml import etree
|
||||
from web_fragments.fragment import Fragment
|
||||
from webob import Response
|
||||
from xblock.core import XBlock
|
||||
from xblock.exceptions import NoSuchServiceError
|
||||
from xblock.fields import Integer, ReferenceValueDict, Scope, String
|
||||
from xmodule.mako_block import MakoTemplateBlockBase
|
||||
from xmodule.modulestore.inheritance import UserPartitionList
|
||||
@@ -172,10 +173,20 @@ class SplitTestBlock( # lint-amnesty, pylint: disable=abstract-method
|
||||
def child(self):
|
||||
"""
|
||||
Return the user bound child block for the partition or None.
|
||||
|
||||
Handles the AttributeError exception that may occur when attempting to retrieve
|
||||
an icon for the split_test xblock within the CMS.
|
||||
"""
|
||||
if self.child_block is not None:
|
||||
return self.runtime.get_block_for_descriptor(self.child_block)
|
||||
else:
|
||||
try:
|
||||
if self.child_block is not None:
|
||||
return self.runtime.get_block_for_descriptor(self.child_block)
|
||||
else:
|
||||
return None
|
||||
except AttributeError:
|
||||
log.warning(
|
||||
"Error while getting block instance for descriptor with location: [%s]",
|
||||
self.location
|
||||
)
|
||||
return None
|
||||
|
||||
def get_child_block_by_location(self, location):
|
||||
@@ -212,13 +223,31 @@ class SplitTestBlock( # lint-amnesty, pylint: disable=abstract-method
|
||||
def get_child_blocks(self):
|
||||
"""
|
||||
For grading--return just the chosen child.
|
||||
|
||||
Handles the NoSuchServiceError and ValueError exception that may occur when attempting to retrieve
|
||||
an icon for the split_test xblock within the CMS.
|
||||
"""
|
||||
group_id = self.get_group_id()
|
||||
try:
|
||||
group_id = self.get_group_id()
|
||||
except NoSuchServiceError:
|
||||
log.warning(
|
||||
"Error while getting user service in runtime with location: [%s]",
|
||||
self.location
|
||||
)
|
||||
return []
|
||||
except ValueError:
|
||||
log.warning(
|
||||
"Error while getting group ID for partition with location: [%s]",
|
||||
self.location
|
||||
)
|
||||
return []
|
||||
|
||||
if group_id is None:
|
||||
return []
|
||||
|
||||
# group_id_to_child comes from json, so it has to have string keys
|
||||
str_group_id = str(group_id)
|
||||
child_block = None
|
||||
if str_group_id in self.group_id_to_child:
|
||||
child_location = self.group_id_to_child[str_group_id]
|
||||
child_block = self.get_child_block_by_location(child_location)
|
||||
|
||||
Reference in New Issue
Block a user