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:
ruzniaievdm
2024-03-12 17:07:32 +02:00
committed by GitHub
parent 844db29d64
commit cd5c4c992b
9 changed files with 300 additions and 47 deletions

View File

@@ -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

View File

@@ -21,4 +21,4 @@ from .videos import (
VideoUsageSerializer,
VideoDownloadSerializer
)
from .vertical_block import ContainerHandlerSerializer
from .vertical_block import ContainerHandlerSerializer, VerticalContainerSerializer

View File

@@ -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()

View File

@@ -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

View File

@@ -15,4 +15,4 @@ from .videos import (
VideoDownloadView
)
from .help_urls import HelpUrlsView
from .vertical_block import ContainerHandlerView
from .vertical_block import ContainerHandlerView, VerticalContainerView

View File

@@ -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)

View File

@@ -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)

View File

@@ -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),
}
)

View File

@@ -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)