From 990a6f11a251799b36055618f0250926f6b864df Mon Sep 17 00:00:00 2001 From: ruzniaievdm Date: Mon, 8 Jan 2024 19:33:18 +0200 Subject: [PATCH] feat: [AXIMST-26] API endpoint to return components in unit (#2488) --- .../contentstore/rest_api/v1/mixins.py | 25 ++- .../rest_api/v1/serializers/__init__.py | 1 + .../rest_api/v1/serializers/vertical_block.py | 110 +++++++++++ .../contentstore/rest_api/v1/urls.py | 13 +- .../rest_api/v1/views/__init__.py | 1 + .../v1/views/tests/test_vertical_block.py | 178 ++++++++++++++++++ .../rest_api/v1/views/vertical_block.py | 94 +++++++-- cms/djangoapps/contentstore/utils.py | 2 +- 8 files changed, 406 insertions(+), 18 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py diff --git a/cms/djangoapps/contentstore/rest_api/v1/mixins.py b/cms/djangoapps/contentstore/rest_api/v1/mixins.py index 849a4834905e..3ac4795680ff 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/mixins.py +++ b/cms/djangoapps/contentstore/rest_api/v1/mixins.py @@ -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 @@ 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 +46,20 @@ 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) + 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 diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index 7e99a729abb3..74bcb4221fd6 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -21,3 +21,4 @@ VideoUsageSerializer, VideoDownloadSerializer ) +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 new file mode 100644 index 000000000000..375643a6e852 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -0,0 +1,110 @@ +""" +API Serializers for unit page +""" + +from django.urls import reverse +from rest_framework import serializers + +from cms.djangoapps.contentstore.helpers import ( + xblock_studio_url, + xblock_type_display_name, +) + + +class ChildAncestorSerializer(serializers.Serializer): + """ + Serializer for representing child blocks in the ancestor XBlock. + """ + + url = serializers.SerializerMethodField() + display_name = serializers.CharField(source="display_name_with_default") + + def get_url(self, obj): + """ + Method to generate studio URL for the child block. + """ + return xblock_studio_url(obj) + + +class AncestorXBlockSerializer(serializers.Serializer): + """ + Serializer for representing the ancestor XBlock and its children. + """ + + children = ChildAncestorSerializer(many=True) + title = serializers.CharField() + is_last = serializers.BooleanField() + + +class ContainerXBlock(serializers.Serializer): + """ + Serializer for representing XBlock data. Doesn't include all data about XBlock. + """ + + display_name = serializers.CharField(source="display_name_with_default") + display_type = serializers.SerializerMethodField() + category = serializers.CharField() + + def get_display_type(self, obj): + """ + Method to get the display type name for the container XBlock. + """ + return xblock_type_display_name(obj) + + +class ContainerHandlerSerializer(serializers.Serializer): + """ + Serializer for container handler + """ + + language_code = serializers.CharField() + action = serializers.CharField() + xblock = ContainerXBlock() + is_unit_page = serializers.BooleanField() + is_collapsible = serializers.BooleanField() + position = serializers.IntegerField(min_value=1) + prev_url = serializers.CharField(allow_null=True) + next_url = serializers.CharField(allow_null=True) + new_unit_category = serializers.CharField() + outline_url = serializers.CharField() + ancestor_xblocks = AncestorXBlockSerializer(many=True) + component_templates = serializers.ListField(child=serializers.DictField()) + xblock_info = serializers.DictField() + draft_preview_link = serializers.CharField() + published_preview_link = serializers.CharField() + show_unit_tags = serializers.BooleanField() + user_clipboard = serializers.DictField() + is_fullwidth_content = serializers.BooleanField() + assets_url = serializers.SerializerMethodField() + unit_block_id = serializers.CharField(source="unit.location.block_id") + subsection_location = serializers.CharField(source="subsection.location") + + def get_assets_url(self, obj): + """ + Method to get the assets URL based on the course id. + """ + + context_course = obj.get("context_course", None) + if context_course: + return reverse( + "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 1af7cf46a675..7dcbcaf1c199 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -19,7 +19,8 @@ ProctoringErrorsView, HelpUrlsView, VideoUsageView, - VideoDownloadView + VideoDownloadView, + VerticalContainerView, ) app_name = 'v1' @@ -100,6 +101,16 @@ CourseRerunView.as_view(), name="course_rerun" ), + re_path( + fr'^container_handler/{settings.USAGE_KEY_PATTERN}$', + 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 57d68ebd081f..4b302d4235f1 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -15,3 +15,4 @@ VideoDownloadView ) from .help_urls import HelpUrlsView +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 new file mode 100644 index 000000000000..23a318c5f5ff --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -0,0 +1,178 @@ +""" +Unit tests for the vertical block. +""" +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 import ( + ModuleStoreEnum, +) # lint-amnesty, pylint: disable=wrong-import-order + + +class BaseXBlockContainer(CourseTestCase): + """ + Base xBlock container handler. + + Contains common function for processing course xblocks. + """ + + view_name = None + + def setUp(self): + super().setUp() + self.store = modulestore() + self.setup_xblock() + + def setup_xblock(self): + """ + Set up XBlock objects for testing purposes. + + This method creates XBlock objects representing a course structure with chapters, + sequentials, verticals and others. + """ + 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): + """ + Creates a block without publishing it. + """ + return BlockFactory.create( + parent_location=parent, + category=category, + display_name=display_name, + modulestore=self.store, + publish_item=False, + user_id=self.user.id, + **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) + 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) + 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 3f9a04851173..43214726cd7e 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -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( @@ -141,3 +135,73 @@ def get(self, request: Request, usage_key_string: str): }) 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 9cbb1ac426a9..3b0be88d4d6b 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1787,7 +1787,7 @@ def _get_course_index_context(request, course_key, course_block): return course_index_context -def get_container_handler_context(request, usage_key, course, xblock): +def get_container_handler_context(request, usage_key, course, xblock): # pylint: disable=too-many-statements """ Utils is used to get context for container xblock requests. It is used for both DRF and django views.