From 7eaadc144a9a927a285bc233896a054f4100763a Mon Sep 17 00:00:00 2001 From: Vivek Ambaliya Date: Tue, 30 Dec 2025 09:33:04 +0000 Subject: [PATCH 1/3] feat: add new API to retrieve all components in a unit --- .../contentstore/rest_api/v1/urls.py | 6 + .../rest_api/v1/views/__init__.py | 7 +- .../rest_api/v1/views/unit_handler.py | 129 ++++++++++++++++++ 3 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 685a81d778ce..8a94f0b0e040 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -25,6 +25,7 @@ HomePageView, ProctoredExamSettingsView, ProctoringErrorsView, + UnitComponentsView, VideoDownloadView, VideoUsageView, vertical_container_children_redirect_view, @@ -144,6 +145,11 @@ CourseWaffleFlagsView.as_view(), name="course_waffle_flags" ), + re_path( + fr'^unit_handler/{settings.USAGE_KEY_PATTERN}$', + UnitComponentsView.as_view(), + name="unit_components" + ), # 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 d4fcfd5f2e3f..25c0157904e9 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -14,9 +14,6 @@ from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView from .settings import CourseSettingsView from .textbooks import CourseTextbooksView +from .unit_handler import UnitComponentsView from .vertical_block import ContainerHandlerView, vertical_container_children_redirect_view -from .videos import ( - CourseVideosView, - VideoDownloadView, - VideoUsageView, -) +from .videos import CourseVideosView, VideoDownloadView, VideoUsageView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py b/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py new file mode 100644 index 000000000000..0740152e4fab --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py @@ -0,0 +1,129 @@ +"""API Views for unit components handler""" + +import logging + +import edx_api_doc_tools as apidocs +from django.http import HttpResponseBadRequest +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.rest_api.v1.mixins import ContainerHandlerMixin +from openedx.core.lib.api.view_utils import view_auth_classes +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError + +log = logging.getLogger(__name__) + + +@view_auth_classes(is_authenticated=True) +class UnitComponentsView(APIView, ContainerHandlerMixin): + """ + View to get all components in a unit by usage key. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "usage_key_string", + apidocs.ParameterLocation.PATH, + description="Unit usage key", + ), + ], + responses={ + 200: "List of components in the unit", + 400: "Invalid usage key or unit not found.", + 401: "The requester is not authenticated.", + 404: "The requested unit does not exist.", + }, + ) + def get(self, request: Request, usage_key_string: str): + """ + Get all components in a unit. + + **Example Request** + + GET /api/contentstore/v1/unit_handler/{usage_key_string} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a dict with a list of all components + in the unit, including their display names, block types, and block IDs. + + **Example Response** + + ```json + { + "unit_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_id", + "display_name": "My Unit", + "components": [ + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@video_id", + "block_type": "video", + "display_name": "Introduction Video" + }, + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_id", + "block_type": "html", + "display_name": "Text Content" + }, + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@problem_id", + "block_type": "problem", + "display_name": "Practice Problem" + } + ] + } + ``` + """ + try: + usage_key = UsageKey.from_string(usage_key_string) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Invalid usage key: {usage_key_string}, error: {str(e)}") + return HttpResponseBadRequest("Invalid usage key format") + + try: + # Get the unit xblock + unit_xblock = modulestore().get_item(usage_key) + + # Verify it's a vertical (unit) + if unit_xblock.category != "vertical": + return HttpResponseBadRequest( + "The provided usage key is not a unit (vertical)" + ) + + components = [] + + # Get all children (components) of the unit + if unit_xblock.has_children: + for child_usage_key in unit_xblock.children: + try: + child_xblock = modulestore().get_item(child_usage_key) + components.append( + { + "block_id": str(child_xblock.location), + "block_type": child_xblock.category, + "display_name": child_xblock.display_name_with_default, + } + ) + except ItemNotFoundError: + log.warning(f"Child block not found: {child_usage_key}") + continue + + response_data = { + "unit_id": str(usage_key), + "display_name": unit_xblock.display_name_with_default, + "components": components, + } + + return Response(response_data) + + except ItemNotFoundError: + log.error(f"Unit not found: {usage_key_string}") + return HttpResponseBadRequest("Unit not found") + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Error retrieving unit components: {str(e)}") + return HttpResponseBadRequest(f"Error retrieving unit components: {str(e)}") From 173637db2cb485a3daa76efa8510469c61a0e1d2 Mon Sep 17 00:00:00 2001 From: Vivek Ambaliya Date: Thu, 8 Jan 2026 06:56:34 +0000 Subject: [PATCH 2/3] feat: add waffle flag to enable unit expanded view --- .../v1/serializers/course_waffle_flags.py | 9 +++++++++ .../rest_api/v1/views/unit_handler.py | 8 +++++++- cms/djangoapps/contentstore/toggles.py | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py index 3efb7b6226d4..fde90163f803 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py @@ -11,6 +11,7 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): """ Serializer for course waffle flags """ + use_new_home_page = serializers.SerializerMethodField() use_new_custom_pages = serializers.SerializerMethodField() use_new_schedule_details_page = serializers.SerializerMethodField() @@ -31,6 +32,7 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): use_react_markdown_editor = serializers.SerializerMethodField() use_video_gallery_flow = serializers.SerializerMethodField() enable_course_optimizer_check_prev_run_links = serializers.SerializerMethodField() + enable_unit_expanded_view = serializers.SerializerMethodField() def get_course_key(self): """ @@ -175,3 +177,10 @@ def get_enable_course_optimizer_check_prev_run_links(self, obj): """ course_key = self.get_course_key() return toggles.enable_course_optimizer_check_prev_run_links(course_key) + + def get_enable_unit_expanded_view(self, obj): + """ + Method to get the enable_unit_expanded_view waffle flag + """ + course_key = self.get_course_key() + return toggles.enable_unit_expanded_view(course_key) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py b/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py index 0740152e4fab..18a2376b3922 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py @@ -3,13 +3,14 @@ import logging import edx_api_doc_tools as apidocs -from django.http import HttpResponseBadRequest +from django.http import HttpResponseBadRequest, HttpResponseForbidden 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.rest_api.v1.mixins import ContainerHandlerMixin +from cms.djangoapps.contentstore.toggles import enable_unit_expanded_view from openedx.core.lib.api.view_utils import view_auth_classes from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError @@ -95,6 +96,11 @@ def get(self, request: Request, usage_key_string: str): "The provided usage key is not a unit (vertical)" ) + if not enable_unit_expanded_view(unit_xblock.location.course_key): + return HttpResponseForbidden( + "Unit expanded view is disabled for this course" + ) + components = [] # Get all children (components) of the unit diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index c287f8c4dbec..c3dba4a6f4a4 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -682,3 +682,23 @@ def enable_course_optimizer_check_prev_run_links(course_key): Returns a boolean if previous run course optimizer feature is enabled for the given course. """ return ENABLE_COURSE_OPTIMIZER_CHECK_PREV_RUN_LINKS.is_enabled(course_key) + + +# .. toggle_name: contentstore.enable_unit_expanded_view +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: When enabled, the Unit Expanded View feature in the Course Outline is activated. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2026-01-01 +# .. toggle_target_removal_date: 2026-06-01 +# .. toggle_tickets: TNL2-473 +ENABLE_UNIT_EXPANDED_VIEW = CourseWaffleFlag( + f"{CONTENTSTORE_NAMESPACE}.enable_unit_expanded_view", __name__ +) + + +def enable_unit_expanded_view(course_key): + """ + Returns a boolean if the Unit Expanded View feature is enabled for the given course. + """ + return ENABLE_UNIT_EXPANDED_VIEW.is_enabled(course_key) From 2b769b8404357d75aec3276c0678f90f62ed3c3b Mon Sep 17 00:00:00 2001 From: Vivek Ambaliya Date: Thu, 8 Jan 2026 11:32:11 +0000 Subject: [PATCH 3/3] fix: test cases --- .../rest_api/v1/views/tests/test_course_waffle_flags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py index f45cc48810d6..a788ce4af3b5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py @@ -38,6 +38,7 @@ class CourseWaffleFlagsViewTest(CourseTestCase): "use_react_markdown_editor": False, "use_video_gallery_flow": False, "enable_course_optimizer_check_prev_run_links": False, + "enable_unit_expanded_view": False, } def setUp(self):