From 9164b5ad8012f2dd552c8d7b8f6b15d5af185ad3 Mon Sep 17 00:00:00 2001 From: ruzniaievdm Date: Fri, 5 Jan 2024 16:36:16 +0200 Subject: [PATCH] feat: [AXIMST-26] API endpoint to return components in unit --- .../contentstore/rest_api/v1/mixins.py | 21 ++- .../rest_api/v1/serializers/__init__.py | 2 +- .../rest_api/v1/serializers/vertical_block.py | 18 ++ .../contentstore/rest_api/v1/urls.py | 8 +- .../rest_api/v1/views/__init__.py | 2 +- .../v1/views/tests/test_vertical_block.py | 159 +++++++++++++++--- .../rest_api/v1/views/vertical_block.py | 93 ++++++++-- cms/djangoapps/contentstore/utils.py | 2 +- 8 files changed, 261 insertions(+), 44 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/mixins.py b/cms/djangoapps/contentstore/rest_api/v1/mixins.py index 849a4834905e..da8c5781662d 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/mixins.py +++ b/cms/djangoapps/contentstore/rest_api/v1/mixins.py @@ -4,6 +4,9 @@ import json 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 @@ -30,7 +33,7 @@ def test_permissions_unauthenticated(self): 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 +43,19 @@ def test_permissions_unauthorized(self): 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) + except InvalidKeyError: + raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + return usage_key diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index 6fdd9c16737a..5a567282c1fd 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -20,4 +20,4 @@ VideoUsageSerializer, VideoDownloadSerializer ) -from .vertical_block import ContainerHandlerSerializer +from .vertical_block import ContainerHandlerSerializer, VerticalContainerSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index c5b54e200e75..375643a6e852 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -90,3 +90,21 @@ def get_assets_url(self, obj): "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() diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 52674fcfc90f..fabcfbb079b0 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -20,7 +20,8 @@ ProctoringErrorsView, HelpUrlsView, VideoUsageView, - VideoDownloadView + VideoDownloadView, + VerticalContainerView, ) app_name = 'v1' @@ -101,6 +102,11 @@ 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 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index 802c9ccbd717..641eddde6054 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -14,4 +14,4 @@ VideoDownloadView ) from .help_urls import HelpUrlsView -from .vertical_block import ContainerHandlerView +from .vertical_block import ContainerHandlerView, VerticalContainerView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py index ff117c5ecfe6..23a318c5f5ff 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -5,63 +5,174 @@ 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", + ) + + 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): + 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) + + 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) + + +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) + 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) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py index a5c860f3fc67..a397a549091b 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -1,35 +1,28 @@ """ API Views for unit page """ import edx_api_doc_tools as apidocs -from django.http import Http404 -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import UsageKey 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.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 +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( @@ -130,3 +123,73 @@ def get(self, request: Request, usage_key_string: str): context = get_container_handler_context(request, usage_key) 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) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index f447912a5e83..bb125cf7e95b 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1691,7 +1691,7 @@ def get_course_videos_context(course_block, pagination_conf, course_key=None): return course_video_context -def get_container_handler_context(request, usage_key): +def get_container_handler_context(request, usage_key): # pylint: disable=too-many-statements """ Utils is used to get context for container xblock requests. It is used for both DRF and django views.