refactor: course container children api [FC-0097] (#37375)

* feat: course container children view

* refactor: rename

* refactor: include children info in upstream info of container children

* fix: tests

* fix: test

* refactor: children check
This commit is contained in:
Navin Karkera
2025-09-24 11:27:31 +05:30
committed by GitHub
parent 2b478146f8
commit 5d9fc2442f
8 changed files with 240 additions and 196 deletions

View File

@@ -18,7 +18,7 @@ from .proctoring import (
)
from .settings import CourseSettingsSerializer
from .textbooks import CourseTextbooksSerializer
from .vertical_block import ContainerHandlerSerializer, VerticalContainerSerializer
from .vertical_block import ContainerHandlerSerializer, ContainerChildrenSerializer
from .videos import (
CourseVideosSerializer,
VideoDownloadSerializer,

View File

@@ -105,6 +105,15 @@ class ContainerHandlerSerializer(serializers.Serializer):
return None
class UpstreamChildrenInfoSerializer(serializers.Serializer):
"""
Serializer holding the information about the children of an xblock that is syncing.
"""
name = serializers.CharField()
upstream = serializers.CharField(allow_null=True)
id = serializers.CharField()
class UpstreamLinkSerializer(serializers.Serializer):
"""
Serializer holding info for syncing a block with its upstream (eg, a library block).
@@ -115,9 +124,12 @@ class UpstreamLinkSerializer(serializers.Serializer):
version_declined = serializers.IntegerField(allow_null=True)
error_message = serializers.CharField(allow_null=True)
ready_to_sync = serializers.BooleanField()
is_modified = serializers.BooleanField()
has_top_level_parent = serializers.BooleanField()
ready_to_sync_children = UpstreamChildrenInfoSerializer(many=True, required=False)
class ChildVerticalContainerSerializer(serializers.Serializer):
class ContainerChildSerializer(serializers.Serializer):
"""
Serializer for representing a xblock child of vertical container.
"""
@@ -160,11 +172,11 @@ class ChildVerticalContainerSerializer(serializers.Serializer):
return actions
class VerticalContainerSerializer(serializers.Serializer):
class ContainerChildrenSerializer(serializers.Serializer):
"""
Serializer for representing a vertical container with state and children.
"""
children = ChildVerticalContainerSerializer(many=True)
children = ContainerChildSerializer(many=True)
is_published = serializers.BooleanField()
can_paste_component = serializers.BooleanField()

View File

@@ -1,32 +1,33 @@
""" Contenstore API v1 URLs. """
from django.conf import settings
from django.urls import re_path, path
from django.urls import path, re_path
from openedx.core.constants import COURSE_ID_PATTERN
from .views import (
ContainerChildrenView,
ContainerHandlerView,
CourseCertificatesView,
CourseDetailsView,
CourseTeamView,
CourseTextbooksView,
CourseIndexView,
CourseGradingView,
CourseGroupConfigurationsView,
CourseIndexView,
CourseRerunView,
CourseSettingsView,
CourseTeamView,
CourseTextbooksView,
CourseVideosView,
CourseWaffleFlagsView,
HomePageView,
HelpUrlsView,
HomePageCoursesView,
HomePageLibrariesView,
HomePageView,
ProctoredExamSettingsView,
ProctoringErrorsView,
HelpUrlsView,
VideoUsageView,
VideoDownloadView,
VerticalContainerView,
VideoUsageView,
vertical_container_children_redirect_view,
)
app_name = 'v1'
@@ -127,11 +128,17 @@ urlpatterns = [
ContainerHandlerView.as_view(),
name="container_handler"
),
# Deprecated url, please use `container_children` url below
re_path(
fr'^container/vertical/{settings.USAGE_KEY_PATTERN}/children$',
VerticalContainerView.as_view(),
vertical_container_children_redirect_view,
name="container_vertical"
),
re_path(
fr'^container/{settings.USAGE_KEY_PATTERN}/children$',
ContainerChildrenView.as_view(),
name="container_children"
),
re_path(
fr'^course_waffle_flags(?:/{COURSE_ID_PATTERN})?$',
CourseWaffleFlagsView.as_view(),

View File

@@ -3,10 +3,10 @@ Views for v1 contentstore API.
"""
from .certificates import CourseCertificatesView
from .course_details import CourseDetailsView
from .course_index import CourseIndexView
from .course_index import ContainerChildrenView, CourseIndexView
from .course_rerun import CourseRerunView
from .course_waffle_flags import CourseWaffleFlagsView
from .course_team import CourseTeamView
from .course_waffle_flags import CourseWaffleFlagsView
from .grading import CourseGradingView
from .group_configurations import CourseGroupConfigurationsView
from .help_urls import HelpUrlsView
@@ -14,7 +14,7 @@ from .home import HomePageCoursesView, HomePageLibrariesView, HomePageView
from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView
from .settings import CourseSettingsView
from .textbooks import CourseTextbooksView
from .vertical_block import ContainerHandlerView, VerticalContainerView
from .vertical_block import ContainerHandlerView, vertical_container_children_redirect_view
from .videos import (
CourseVideosView,
VideoDownloadView,

View File

@@ -1,5 +1,7 @@
"""API Views for course index"""
import logging
import edx_api_doc_tools as apidocs
from django.conf import settings
from opaque_keys.edx.keys import CourseKey
@@ -8,10 +10,24 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseIndexSerializer
from cms.djangoapps.contentstore.utils import get_course_index_context
from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin
from cms.djangoapps.contentstore.rest_api.v1.serializers import (
CourseIndexSerializer,
ContainerChildrenSerializer,
)
from cms.djangoapps.contentstore.utils import (
get_course_index_context,
get_user_partition_info,
get_visibility_partition_info,
get_xblock_render_error,
get_xblock_validation_messages,
)
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock
from cms.lib.xblock.upstream_sync import UpstreamLink
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
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
@view_auth_classes(is_authenticated=True)
@@ -98,3 +114,169 @@ class CourseIndexView(DeveloperErrorViewMixin, APIView):
serializer = CourseIndexSerializer(course_index_context)
return Response(serializer.data)
@view_auth_classes(is_authenticated=True)
class ContainerChildrenView(APIView, ContainerHandlerMixin):
"""
View for container xblock requests to get state and children data.
"""
@apidocs.schema(
parameters=[
apidocs.string_parameter(
"usage_key_string",
apidocs.ParameterLocation.PATH,
description="Container usage key",
),
],
responses={
200: ContainerChildrenSerializer,
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/{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",
"block_type": "drag-and-drop-v2",
"user_partition_info": {},
"user_partitions": {}
"upstream_link": null,
"actions": {
"can_copy": true,
"can_duplicate": true,
"can_move": true,
"can_manage_access": true,
"can_delete": true,
"can_manage_tags": true,
},
"has_validation_error": false,
"validation_errors": [],
},
{
"name": "Video",
"block_id": "block-v1:org+101+101+type@video+block@0e3d39b12d7c4345981bda6b3511a9bf",
"block_type": "video",
"user_partition_info": {},
"user_partitions": {}
"upstream_link": {
"upstream_ref": "lb:org:mylib:video:404",
"version_synced": 16
"version_available": null,
"error_message": "Linked library item not found: lb:org:mylib:video:404",
"ready_to_sync": false,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
"can_move": true,
"can_manage_access": true,
"can_delete": true,
"can_manage_tags": true,
}
"validation_messages": [],
"render_error": "",
},
{
"name": "Text",
"block_id": "block-v1:org+101+101+type@html+block@3e3fa1f88adb4a108cd14e9002143690",
"block_type": "html",
"user_partition_info": {},
"user_partitions": {},
"upstream_link": {
"upstream_ref": "lb:org:mylib:html:abcd",
"version_synced": 43,
"version_available": 49,
"error_message": null,
"ready_to_sync": true,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
"can_move": true,
"can_manage_access": true,
"can_delete": true,
"can_manage_tags": true,
},
"validation_messages": [
{
"text": "This component's access settings contradict its parent's access settings.",
"type": "error"
}
],
"render_error": "Unterminated control keyword: 'if' in file '../problem.html'",
},
],
"is_published": false,
"can_paste_component": true,
}
```
"""
usage_key = self.get_object(usage_key_string)
current_xblock = get_xblock(usage_key, request.user)
is_course = current_xblock.scope_ids.usage_id.context_key.is_course
with modulestore().bulk_operations(usage_key.course_key):
# load course once to reuse it for user_partitions query
course = modulestore().get_course(current_xblock.location.course_key)
children = []
if current_xblock.has_children:
for child in current_xblock.children:
child_info = modulestore().get_item(child)
user_partition_info = get_visibility_partition_info(child_info, course=course)
user_partitions = get_user_partition_info(child_info, course=course)
upstream_link = UpstreamLink.try_get_for_block(child_info, log_error=False)
validation_messages = get_xblock_validation_messages(child_info)
render_error = get_xblock_render_error(request, child_info)
children.append({
"xblock": child_info,
"name": child_info.display_name_with_default,
"block_id": child_info.location,
"block_type": child_info.location.block_type,
"user_partition_info": user_partition_info,
"user_partitions": user_partitions,
"upstream_link": (
# If the block isn't linked to an upstream (which is by far the most common case) then just
# make this field null, which communicates the same info, but with less noise.
upstream_link.to_json(include_child_info=True) if upstream_link.upstream_ref
else None
),
"validation_messages": validation_messages,
"render_error": render_error,
})
is_published = False
try:
is_published = not modulestore().has_changes(current_xblock)
except ItemNotFoundError:
logging.error('Could not find any changes for block [%s]', usage_key)
container_data = {
"children": children,
"is_published": is_published,
"can_paste_component": is_course,
}
serializer = ContainerChildrenSerializer(container_data)
return Response(serializer.data)

View File

@@ -195,7 +195,7 @@ class ContainerVerticalViewTest(BaseXBlockContainer):
Unit tests for the ContainerVerticalViewTest.
"""
view_name = "container_vertical"
view_name = "container_children"
def test_success_response(self):
"""
@@ -279,6 +279,8 @@ class ContainerVerticalViewTest(BaseXBlockContainer):
"version_declined": None,
"error_message": "Linked upstream library block was not found in the system",
"ready_to_sync": False,
"has_top_level_parent": False,
"is_modified": False,
},
"user_partition_info": expected_user_partition_info,
"user_partitions": expected_user_partitions,

View File

@@ -1,33 +1,26 @@
""" API Views for unit page """
import logging
import edx_api_doc_tools as apidocs
from django.http import HttpResponseBadRequest
from django.http import HttpResponseBadRequest, HttpResponsePermanentRedirect
from django.urls import reverse
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,
get_user_partition_info,
get_visibility_partition_info,
get_xblock_validation_messages,
get_xblock_render_error,
)
from cms.djangoapps.contentstore.views.component import _get_item_in_course
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock
from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin
from cms.djangoapps.contentstore.rest_api.v1.serializers import (
ContainerHandlerSerializer,
VerticalContainerSerializer,
)
from cms.lib.xblock.upstream_sync import UpstreamLink
from cms.djangoapps.contentstore.utils import (
get_container_handler_context,
)
from cms.djangoapps.contentstore.views.component import _get_item_in_course
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
log = logging.getLogger(__name__)
@@ -154,167 +147,16 @@ class ContainerHandlerView(APIView, ContainerHandlerMixin):
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.
def vertical_container_children_redirect_view(request: Request, usage_key_string: str):
"""
Redirects GET requests to container_children url.
@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.",
},
This view will return a 301 Moved Permanently response to the provided
usage_key string and automatically redirect the browser to the correct URL.
"""
redirect_location = reverse(
'cms.djangoapps.contentstore:v1:container_children',
kwargs={'usage_key_string': usage_key_string},
)
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",
"block_type": "drag-and-drop-v2",
"user_partition_info": {},
"user_partitions": {}
"upstream_link": null,
"actions": {
"can_copy": true,
"can_duplicate": true,
"can_move": true,
"can_manage_access": true,
"can_delete": true,
"can_manage_tags": true,
},
"has_validation_error": false,
"validation_errors": [],
},
{
"name": "Video",
"block_id": "block-v1:org+101+101+type@video+block@0e3d39b12d7c4345981bda6b3511a9bf",
"block_type": "video",
"user_partition_info": {},
"user_partitions": {}
"upstream_link": {
"upstream_ref": "lb:org:mylib:video:404",
"version_synced": 16
"version_available": null,
"error_message": "Linked library item not found: lb:org:mylib:video:404",
"ready_to_sync": false,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
"can_move": true,
"can_manage_access": true,
"can_delete": true,
"can_manage_tags": true,
}
"validation_messages": [],
"render_error": "",
},
{
"name": "Text",
"block_id": "block-v1:org+101+101+type@html+block@3e3fa1f88adb4a108cd14e9002143690",
"block_type": "html",
"user_partition_info": {},
"user_partitions": {},
"upstream_link": {
"upstream_ref": "lb:org:mylib:html:abcd",
"version_synced": 43,
"version_available": 49,
"error_message": null,
"ready_to_sync": true,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
"can_move": true,
"can_manage_access": true,
"can_delete": true,
"can_manage_tags": true,
},
"validation_messages": [
{
"text": "This component's access settings contradict its parent's access settings.",
"type": "error"
}
],
"render_error": "Unterminated control keyword: 'if' in file '../problem.html'",
},
],
"is_published": false,
"can_paste_component": true,
}
```
"""
usage_key = self.get_object(usage_key_string)
current_xblock = get_xblock(usage_key, request.user)
is_course = current_xblock.scope_ids.usage_id.context_key.is_course
with modulestore().bulk_operations(usage_key.course_key):
# load course once to reuse it for user_partitions query
course = modulestore().get_course(current_xblock.location.course_key)
children = []
if hasattr(current_xblock, "children"):
for child in current_xblock.children:
child_info = modulestore().get_item(child)
user_partition_info = get_visibility_partition_info(child_info, course=course)
user_partitions = get_user_partition_info(child_info, course=course)
upstream_link = UpstreamLink.try_get_for_block(child_info, log_error=False)
validation_messages = get_xblock_validation_messages(child_info)
render_error = get_xblock_render_error(request, child_info)
children.append({
"xblock": child_info,
"name": child_info.display_name_with_default,
"block_id": child_info.location,
"block_type": child_info.location.block_type,
"user_partition_info": user_partition_info,
"user_partitions": user_partitions,
"upstream_link": (
# If the block isn't linked to an upstream (which is by far the most common case) then just
# make this field null, which communicates the same info, but with less noise.
upstream_link.to_json() if upstream_link.upstream_ref
else None
),
"validation_messages": validation_messages,
"render_error": render_error,
})
is_published = False
try:
is_published = not modulestore().has_changes(current_xblock)
except ItemNotFoundError:
logging.error('Could not find any changes for block [%s]', usage_key)
container_data = {
"children": children,
"is_published": is_published,
"can_paste_component": is_course,
}
serializer = VerticalContainerSerializer(container_data)
return Response(serializer.data)
http_response = HttpResponsePermanentRedirect(redirect_location)
return http_response

View File

@@ -183,7 +183,6 @@ class UpstreamLink:
}
if (
include_child_info
and self.ready_to_sync
and isinstance(self.upstream_key, LibraryContainerLocator)
and self.downstream_key is not None
):