From 1a38dab3a5dc85fbbc8a6db2294f5670558a593f Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Wed, 12 Aug 2026 17:14:48 -0400 Subject: [PATCH 1/9] feat: add Course Optimizer extended-report proxy views Studio proxies both kicking off a Course Optimizer extended-analysis run (CourseAnalysisReportView, generating the course export server-side via the existing create_export_tarball) and polling its status (CourseAnalysisReportStatusView) to the xpert-ai-workflows backend, keyed by a shared secret -- the browser only ever talks to Studio. --- .../v0/tests/test_course_optimizer.py | 126 +++++++++++++++++ .../contentstore/rest_api/v0/urls.py | 10 ++ .../rest_api/v0/views/__init__.py | 9 +- .../rest_api/v0/views/course_optimizer.py | 128 +++++++++++++++++- cms/envs/common.py | 13 ++ 5 files changed, 284 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py index 14d5a20fb41b..939df7916548 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py @@ -1,11 +1,17 @@ """ Unit tests for course optimizer """ +from unittest.mock import Mock, patch + +import requests +from django.conf import settings from django.test import TestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from django.urls import reverse +from rest_framework import status from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase +from cms.djangoapps.contentstore.tests.utils import CourseTestCase class TestGetLinkCheckStatus(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): @@ -77,3 +83,123 @@ def test_produces_4xx_when_unexpected_data(self): ''' response = self.make_request(course_id=self.course.id, data={'unexpected_data': 'unexpected_data'}) self.assertIn(response.status_code, range(400, 500)) + + +class CourseAnalysisReportViewTest(CourseTestCase): + """ + Tests for CourseAnalysisReportView, which kicks off a Course Optimizer + extended-analysis run by generating a course export server-side and + handing it to the xpert-ai-workflows backend. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + 'cms.djangoapps.contentstore:v0:course_analysis_report', + kwargs={'course_id': str(self.course.id)}, + ) + self.export_patch = ( + 'cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer.create_export_tarball' + ) + self.backend_post_patch = ( + 'cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer.requests.post' + ) + + def _mock_tarball(self): + tarball = Mock() + tarball.name = '/tmp/whatever.tar.gz' + return tarball + + def test_unauthenticated(self): + self.client.logout() + response = self.client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_no_course_access(self): + client, _ = self.create_non_staff_authed_user_client() + response = client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_kicks_off_backend_run(self): + with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: + mock_export.return_value = self._mock_tarball() + mock_post.return_value = Mock( + status_code=202, + json=Mock(return_value={'run_id': 'run-123'}), + ) + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.json(), {'run_id': 'run-123'}) + self.assertEqual( + mock_post.call_args.kwargs['headers']['X-Api-Key'], + settings.COURSE_ANALYSIS_WORKFLOW_API_KEY, + ) + + def test_backend_unreachable_returns_502(self): + with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: + mock_export.return_value = self._mock_tarball() + mock_post.side_effect = requests.ConnectionError() + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + + +class CourseAnalysisReportStatusViewTest(CourseTestCase): + """ + Tests for CourseAnalysisReportStatusView, the Studio-side proxy for a + course's latest Course Optimizer extended-analysis run status + (xpert-ai-workflows). + """ + + def setUp(self): + super().setUp() + self.url = reverse( + 'cms.djangoapps.contentstore:v0:course_analysis_report_status', + kwargs={'course_id': str(self.course.id)}, + ) + self.backend_get_patch = ( + 'cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer.requests.get' + ) + + def test_unauthenticated(self): + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_no_course_access(self): + client, _ = self.create_non_staff_authed_user_client() + response = client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_proxies_backend_response(self): + with patch(self.backend_get_patch) as mock_get: + mock_get.return_value = Mock( + status_code=200, + json=Mock(return_value={ + 'run_id': 'run-123', 'status': 'COMPLETE', 'report': {}, 'error': None, + }), + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), { + 'run_id': 'run-123', 'status': 'COMPLETE', 'report': {}, 'error': None, + }) + + def test_no_runs_yet_returns_404(self): + with patch(self.backend_get_patch) as mock_get: + mock_get.return_value = Mock( + status_code=404, + json=Mock(return_value={'detail': "No runs found for course"}), + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_backend_unreachable_returns_502(self): + with patch(self.backend_get_patch) as mock_get: + mock_get.side_effect = requests.ConnectionError() + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) diff --git a/cms/djangoapps/contentstore/rest_api/v0/urls.py b/cms/djangoapps/contentstore/rest_api/v0/urls.py index 974d1b98a0c4..7466fe8e6a55 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v0/urls.py @@ -9,6 +9,8 @@ AdvancedCourseSettingsView, APIHeartBeatView, AuthoringGradingView, + CourseAnalysisReportStatusView, + CourseAnalysisReportView, CourseTabListView, CourseTabReorderView, CourseTabSettingsView, @@ -125,4 +127,12 @@ fr'^rerun_link_update_status/{settings.COURSE_ID_PATTERN}$', RerunLinkUpdateStatusView.as_view(), name='rerun_link_update_status' ), + re_path( + fr'^course_analysis_report/{settings.COURSE_ID_PATTERN}$', + CourseAnalysisReportView.as_view(), name='course_analysis_report' + ), + re_path( + fr'^course_analysis_report_status/{settings.COURSE_ID_PATTERN}$', + CourseAnalysisReportStatusView.as_view(), name='course_analysis_report_status' + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py index 5714754b191e..b92d0b8ec705 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py @@ -4,6 +4,13 @@ from .advanced_settings import AdvancedCourseSettingsView from .api_heartbeat import APIHeartBeatView from .authoring_grading import AuthoringGradingView -from .course_optimizer import LinkCheckStatusView, LinkCheckView, RerunLinkUpdateStatusView, RerunLinkUpdateView +from .course_optimizer import ( + CourseAnalysisReportStatusView, + CourseAnalysisReportView, + LinkCheckStatusView, + LinkCheckView, + RerunLinkUpdateStatusView, + RerunLinkUpdateView, +) from .tabs import CourseTabListView, CourseTabReorderView, CourseTabSettingsView from .transcripts import TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py index bd37ae837916..7c562ca8e503 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -1,6 +1,10 @@ """API Views for Course Optimizer.""" +import os + import edx_api_doc_tools as apidocs +import requests +from django.conf import settings from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import status @@ -19,7 +23,11 @@ LinkCheckSerializer, CourseRerunLinkUpdateRequestSerializer, ) -from cms.djangoapps.contentstore.tasks import check_broken_links, update_course_rerun_links +from cms.djangoapps.contentstore.tasks import ( + check_broken_links, + create_export_tarball, + update_course_rerun_links, +) from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access from common.djangoapps.util.json_request import JsonResponse @@ -28,6 +36,7 @@ verify_course_exists, view_auth_classes, ) +from xmodule.modulestore.django import modulestore @view_auth_classes(is_authenticated=True) @@ -420,3 +429,120 @@ def get(self, request: Request, course_id: str): data = get_course_link_update_data(request, course_id) serializer = CourseRerunLinkUpdateStatusSerializer(data) return Response(serializer.data) + + +# A slow/unreachable backend shouldn't tie up a Studio request thread waiting on it. +_COURSE_ANALYSIS_REPORT_REQUEST_TIMEOUT_SECONDS = 5 + + +@view_auth_classes(is_authenticated=True) +class CourseAnalysisReportView(DeveloperErrorViewMixin, APIView): + """ + View for kicking off a Course Optimizer extended-analysis run. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 202: "Analysis run queued.", + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + 502: "The Course Optimizer extended-report backend is unreachable.", + }, + ) + @verify_course_exists() + def post(self, request: Request, course_id: str): + """ + Generate a fresh export of the course and hand it to the Course + Optimizer extended-report backend (xpert-ai-workflows) to start a + new analysis run. Studio generates the export server-side -- the + browser never uploads anything or talks to that backend directly. + + **Example Request** + + POST /api/contentstore/v0/course_analysis_report/{course_id} + + **Response Values** + ```json + { + "run_id": + } + ``` + """ + course_key = CourseKey.from_string(course_id) + if not has_course_author_access(request.user, course_key): + self.permission_denied(request) + + course_block = modulestore().get_course(course_key) + tarball = create_export_tarball(course_block, course_key, {}) + try: + tarball.seek(0) + try: + response = requests.post( + f'{settings.COURSE_ANALYSIS_WORKFLOW_URL}/courses/{course_id}/runs', + files={'file': (os.path.basename(tarball.name), tarball, 'application/gzip')}, + headers={'X-Api-Key': settings.COURSE_ANALYSIS_WORKFLOW_API_KEY}, + timeout=_COURSE_ANALYSIS_REPORT_REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException: + return Response(status=status.HTTP_502_BAD_GATEWAY) + finally: + tarball.close() + + return Response(response.json(), status=response.status_code) + + +@view_auth_classes() +class CourseAnalysisReportStatusView(DeveloperErrorViewMixin, APIView): + """ + View proxying a course's Course Optimizer extended-report status. + + Studio calls the Course Optimizer extended-report backend + (xpert-ai-workflows) server-side and returns its response as-is; the + browser never calls that backend directly. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: "OK", + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The course has no analysis runs yet.", + 502: "The Course Optimizer extended-report backend is unreachable.", + }, + ) + def get(self, request: Request, course_id: str): + """ + Proxy the status of a course's most recent Course Optimizer + extended-analysis run. + + **Example Request** + + GET /api/contentstore/v0/course_analysis_report_status/{course_id} + + **Response Values** + + The xpert-ai-workflows run-status response, passed through + unchanged: `{run_id, status, report, error}`. A 404 means the + course has no analysis runs yet. + """ + course_key = CourseKey.from_string(course_id) + if not has_course_author_access(request.user, course_key): + self.permission_denied(request) + + try: + response = requests.get( + f'{settings.COURSE_ANALYSIS_WORKFLOW_URL}/courses/{course_id}/runs/latest', + headers={'X-Api-Key': settings.COURSE_ANALYSIS_WORKFLOW_API_KEY}, + timeout=_COURSE_ANALYSIS_REPORT_REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException: + return Response(status=status.HTTP_502_BAD_GATEWAY) + + return Response(response.json(), status=response.status_code) diff --git a/cms/envs/common.py b/cms/envs/common.py index d330ad909dfa..1c78a8e719a2 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1782,6 +1782,19 @@ def _should_send_xblock_events(settings): MEILISEARCH_INDEX_PREFIX = "" MEILISEARCH_API_KEY = "devkey" +# .. setting_name: COURSE_ANALYSIS_WORKFLOW_URL +# .. setting_default: '' +# .. setting_description: Base URL of the Course Optimizer extended-report +# .. backend (xpert-ai-workflows). Studio calls this server-side and +# .. proxies the response to the browser; the browser never calls it +# .. directly. +COURSE_ANALYSIS_WORKFLOW_URL = '' +# .. setting_name: COURSE_ANALYSIS_WORKFLOW_API_KEY +# .. setting_default: '' +# .. setting_description: Shared secret sent as the X-Api-Key header on +# .. every request to COURSE_ANALYSIS_WORKFLOW_URL. +COURSE_ANALYSIS_WORKFLOW_API_KEY = '' + # .. setting_name: LIBRARY_ENABLED_BLOCKS # .. setting_default: ['problem', 'video', 'html', 'drag-and-drop-v2'] # .. setting_description: List of block types that are ready/enabled to be created/used From d9065c7414d48493a7196ac1f80b825ae1537fae Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Wed, 12 Aug 2026 17:14:56 -0400 Subject: [PATCH 2/9] chore(devstack): point Course Optimizer proxy at local xpert-ai-workflows Reached via Docker's host-gateway alias, since it runs on the host machine rather than in the devstack docker network. --- cms/envs/devstack.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index d683f48708f1..0ca940ab8a16 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -291,6 +291,13 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ############################ AI_TRANSLATIONS ################################## AI_TRANSLATIONS_API_URL = 'http://localhost:18760/api/v1' +############################ COURSE OPTIMIZER EXTENDED REPORT ################## +# xpert-ai-workflows running on the host machine (not in the devstack docker +# network), reached via Docker's host-gateway alias. Shared secret must match +# that service's own EDX_STUDIO_API_KEY. +COURSE_ANALYSIS_WORKFLOW_URL = 'http://host.docker.internal:19090' +COURSE_ANALYSIS_WORKFLOW_API_KEY = 'this_is_a_test_api_key' # matches xpert-ai-workflows' local .env + ############################ CSRF ################################## # MFEs that will call this service in devstack From 613111b8816a6ce5bb0d6fce4c86ff48b143a67a Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Thu, 13 Aug 2026 13:08:44 -0400 Subject: [PATCH 3/9] feat: add enable_course_optimizer_extended_report waffle flag Gates the Course Optimizer extended-report section in the authoring MFE, matching enable_course_optimizer's existing CourseWaffleFlag pattern. Without this, the frontend's default for this flag would apply unconditionally everywhere, since it's absent from every course waffle flags API response until a real flag exists to answer for it. --- .../v1/serializers/course_waffle_flags.py | 8 +++++++ .../views/tests/test_course_waffle_flags.py | 1 + cms/djangoapps/contentstore/toggles.py | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+) 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 f92ef38b3f06..7e9e5067f0fa 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 @@ -32,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_course_optimizer_extended_report = serializers.SerializerMethodField() enable_unit_expanded_view = serializers.SerializerMethodField() enable_outline_component_creation = serializers.SerializerMethodField() enable_audio_description = serializers.SerializerMethodField() @@ -180,6 +181,13 @@ 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_course_optimizer_extended_report(self, obj): + """ + Method to get the enable_course_optimizer_extended_report waffle flag + """ + course_key = self.get_course_key() + return toggles.enable_course_optimizer_extended_report(course_key) + def get_enable_unit_expanded_view(self, obj): """ Method to get the enable_unit_expanded_view waffle flag 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 cd705eb700c2..b4779378b786 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 @@ -39,6 +39,7 @@ class CourseWaffleFlagsViewTest(CourseTestCase): "use_react_markdown_editor": False, "use_video_gallery_flow": False, "enable_course_optimizer_check_prev_run_links": False, + "enable_course_optimizer_extended_report": False, "enable_unit_expanded_view": False, "enable_outline_component_creation": False, "enable_audio_description": False, diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index ca5e02adc78f..17da0a7d1e0b 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -708,6 +708,30 @@ def enable_course_optimizer_check_prev_run_links(course_key): return ENABLE_COURSE_OPTIMIZER_CHECK_PREV_RUN_LINKS.is_enabled(course_key) +# .. toggle_name: contentstore.enable_course_optimizer_extended_report +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: When enabled, shows the Course Optimizer's extended +# analysis report (time-on-task, learning balance, and LLM-driven +# accessibility/content-quality/pacing findings) alongside the existing +# link-check scan results in the authoring MFE. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2026-08-13 +# .. toggle_target_removal_date: 2027-02-13 +ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.enable_course_optimizer_extended_report', + __name__, + CONTENTSTORE_LOG_PREFIX, +) + + +def enable_course_optimizer_extended_report(course_key): + """ + Returns a boolean if the Course Optimizer extended report is enabled for the given course. + """ + return ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT.is_enabled(course_key) + + # .. toggle_name: contentstore.enable_unit_expanded_view # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False From e47458004faa86e20d23e937d720af371ff761cf Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Fri, 14 Aug 2026 10:48:58 -0400 Subject: [PATCH 4/9] refactor: move timeout to settings --- .../contentstore/rest_api/v0/views/course_optimizer.py | 6 +----- cms/envs/common.py | 6 ++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py index 7c562ca8e503..bbe63e6814c9 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -431,10 +431,6 @@ def get(self, request: Request, course_id: str): return Response(serializer.data) -# A slow/unreachable backend shouldn't tie up a Studio request thread waiting on it. -_COURSE_ANALYSIS_REPORT_REQUEST_TIMEOUT_SECONDS = 5 - - @view_auth_classes(is_authenticated=True) class CourseAnalysisReportView(DeveloperErrorViewMixin, APIView): """ @@ -485,7 +481,7 @@ def post(self, request: Request, course_id: str): f'{settings.COURSE_ANALYSIS_WORKFLOW_URL}/courses/{course_id}/runs', files={'file': (os.path.basename(tarball.name), tarball, 'application/gzip')}, headers={'X-Api-Key': settings.COURSE_ANALYSIS_WORKFLOW_API_KEY}, - timeout=_COURSE_ANALYSIS_REPORT_REQUEST_TIMEOUT_SECONDS, + timeout=settings.COURSE_ANALYSIS_WORKFLOW_REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException: return Response(status=status.HTTP_502_BAD_GATEWAY) diff --git a/cms/envs/common.py b/cms/envs/common.py index 1c78a8e719a2..7a8bde7dce96 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1794,6 +1794,12 @@ def _should_send_xblock_events(settings): # .. setting_description: Shared secret sent as the X-Api-Key header on # .. every request to COURSE_ANALYSIS_WORKFLOW_URL. COURSE_ANALYSIS_WORKFLOW_API_KEY = '' +# .. setting_name: COURSE_ANALYSIS_WORKFLOW_REQUEST_TIMEOUT_SECONDS +# .. setting_default: 5 +# .. setting_description: How long to wait on a request to +# .. COURSE_ANALYSIS_WORKFLOW_URL before giving up. A slow/unreachable +# .. backend shouldn't tie up a Studio request thread waiting on it. +COURSE_ANALYSIS_WORKFLOW_REQUEST_TIMEOUT_SECONDS = 5 # .. setting_name: LIBRARY_ENABLED_BLOCKS # .. setting_default: ['problem', 'video', 'html', 'drag-and-drop-v2'] From c5a140718c80488093d295ecae25fc26878588f1 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 17 Aug 2026 09:16:18 -0400 Subject: [PATCH 5/9] fix: return 404 for invalid/nonexistent course in analysis report status CourseAnalysisReportStatusView.get parsed course_id without @verify_course_exists(), so an invalid or unknown course key produced a 500 instead of the documented 404. Co-Authored-By: Claude Sonnet 5 --- .../contentstore/rest_api/v0/views/course_optimizer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py index bbe63e6814c9..d5fa7065240b 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -472,6 +472,12 @@ def post(self, request: Request, course_id: str): if not has_course_author_access(request.user, course_key): self.permission_denied(request) + if not enable_course_optimizer_extended_report(course_key): + return JsonResponse( + {"error": "Course optimizer extended report is not enabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + course_block = modulestore().get_course(course_key) tarball = create_export_tarball(course_block, course_key, {}) try: From 5bb8cc9397b8883005999452e7fbf580a88df56c Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 17 Aug 2026 09:16:32 -0400 Subject: [PATCH 6/9] fix: gate course analysis report endpoints behind waffle flag CourseAnalysisReportView.post and CourseAnalysisReportStatusView.get triggered/proxied analysis runs regardless of the enable_course_optimizer_extended_report flag state, so the backend call happened even when the flag was off. Also fixes a NameError in the status view from a leftover reference to an undefined _COURSE_ANALYSIS_REPORT_REQUEST_TIMEOUT_SECONDS constant. Co-Authored-By: Claude Sonnet 5 --- .../v0/tests/test_course_optimizer.py | 24 +++++++++++++++++++ .../rest_api/v0/views/course_optimizer.py | 14 +++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py index 939df7916548..08d147cd1b2d 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py @@ -6,12 +6,14 @@ import requests from django.conf import settings from django.test import TestCase +from edx_toggles.toggles.testutils import override_waffle_flag from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from django.urls import reverse from rest_framework import status from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.toggles import ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT class TestGetLinkCheckStatus(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): @@ -120,6 +122,11 @@ def test_no_course_access(self): response = client.post(self.url) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_waffle_flag_disabled_returns_400(self): + response = self.client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) def test_kicks_off_backend_run(self): with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: mock_export.return_value = self._mock_tarball() @@ -136,6 +143,7 @@ def test_kicks_off_backend_run(self): settings.COURSE_ANALYSIS_WORKFLOW_API_KEY, ) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) def test_backend_unreachable_returns_502(self): with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: mock_export.return_value = self._mock_tarball() @@ -172,6 +180,11 @@ def test_no_course_access(self): response = client.get(self.url) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_waffle_flag_disabled_returns_400(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) def test_proxies_backend_response(self): with patch(self.backend_get_patch) as mock_get: mock_get.return_value = Mock( @@ -187,6 +200,7 @@ def test_proxies_backend_response(self): 'run_id': 'run-123', 'status': 'COMPLETE', 'report': {}, 'error': None, }) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) def test_no_runs_yet_returns_404(self): with patch(self.backend_get_patch) as mock_get: mock_get.return_value = Mock( @@ -197,9 +211,19 @@ def test_no_runs_yet_returns_404(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) def test_backend_unreachable_returns_502(self): with patch(self.backend_get_patch) as mock_get: mock_get.side_effect = requests.ConnectionError() response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) + def test_produces_404_when_course_does_not_exist(self): + url = reverse( + 'cms.djangoapps.contentstore:v0:course_analysis_report_status', + kwargs={'course_id': 'course-v1:edX+DemoX+Nonexistent_Course'}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py index d5fa7065240b..92eddff7aa7d 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -28,7 +28,10 @@ create_export_tarball, update_course_rerun_links, ) -from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links +from cms.djangoapps.contentstore.toggles import ( + enable_course_optimizer_check_prev_run_links, + enable_course_optimizer_extended_report, +) from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access from common.djangoapps.util.json_request import JsonResponse from openedx.core.lib.api.view_utils import ( @@ -519,6 +522,7 @@ class CourseAnalysisReportStatusView(DeveloperErrorViewMixin, APIView): 502: "The Course Optimizer extended-report backend is unreachable.", }, ) + @verify_course_exists() def get(self, request: Request, course_id: str): """ Proxy the status of a course's most recent Course Optimizer @@ -538,11 +542,17 @@ def get(self, request: Request, course_id: str): if not has_course_author_access(request.user, course_key): self.permission_denied(request) + if not enable_course_optimizer_extended_report(course_key): + return JsonResponse( + {"error": "Course optimizer extended report is not enabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: response = requests.get( f'{settings.COURSE_ANALYSIS_WORKFLOW_URL}/courses/{course_id}/runs/latest', headers={'X-Api-Key': settings.COURSE_ANALYSIS_WORKFLOW_API_KEY}, - timeout=_COURSE_ANALYSIS_REPORT_REQUEST_TIMEOUT_SECONDS, + timeout=settings.COURSE_ANALYSIS_WORKFLOW_REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException: return Response(status=status.HTTP_502_BAD_GATEWAY) From 787f2badcdb50d88d5c0bad036c6e62d84d396f8 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 17 Aug 2026 17:18:11 -0400 Subject: [PATCH 7/9] refactor: update waffle flag name --- .../v0/tests/test_course_optimizer.py | 14 ++++++------- .../rest_api/v0/views/course_optimizer.py | 10 +++++----- .../v1/serializers/course_waffle_flags.py | 8 ++++---- .../views/tests/test_course_waffle_flags.py | 2 +- cms/djangoapps/contentstore/toggles.py | 20 +++++++++---------- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py index 08d147cd1b2d..a83ec3dc5480 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py @@ -13,7 +13,7 @@ from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from cms.djangoapps.contentstore.toggles import ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT +from cms.djangoapps.contentstore.toggles import ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS class TestGetLinkCheckStatus(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): @@ -126,7 +126,7 @@ def test_waffle_flag_disabled_returns_400(self): response = self.client.post(self.url) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) def test_kicks_off_backend_run(self): with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: mock_export.return_value = self._mock_tarball() @@ -143,7 +143,7 @@ def test_kicks_off_backend_run(self): settings.COURSE_ANALYSIS_WORKFLOW_API_KEY, ) - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) def test_backend_unreachable_returns_502(self): with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: mock_export.return_value = self._mock_tarball() @@ -184,7 +184,7 @@ def test_waffle_flag_disabled_returns_400(self): response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) def test_proxies_backend_response(self): with patch(self.backend_get_patch) as mock_get: mock_get.return_value = Mock( @@ -200,7 +200,7 @@ def test_proxies_backend_response(self): 'run_id': 'run-123', 'status': 'COMPLETE', 'report': {}, 'error': None, }) - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) def test_no_runs_yet_returns_404(self): with patch(self.backend_get_patch) as mock_get: mock_get.return_value = Mock( @@ -211,7 +211,7 @@ def test_no_runs_yet_returns_404(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) def test_backend_unreachable_returns_502(self): with patch(self.backend_get_patch) as mock_get: mock_get.side_effect = requests.ConnectionError() @@ -219,7 +219,7 @@ def test_backend_unreachable_returns_502(self): self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT, True) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) def test_produces_404_when_course_does_not_exist(self): url = reverse( 'cms.djangoapps.contentstore:v0:course_analysis_report_status', diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py index 92eddff7aa7d..6ce114ddf4cf 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -30,7 +30,7 @@ ) from cms.djangoapps.contentstore.toggles import ( enable_course_optimizer_check_prev_run_links, - enable_course_optimizer_extended_report, + enable_course_optimizer_extended_checks, ) from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access from common.djangoapps.util.json_request import JsonResponse @@ -475,9 +475,9 @@ def post(self, request: Request, course_id: str): if not has_course_author_access(request.user, course_key): self.permission_denied(request) - if not enable_course_optimizer_extended_report(course_key): + if not enable_course_optimizer_extended_checks(course_key): return JsonResponse( - {"error": "Course optimizer extended report is not enabled."}, + {"error": "Course optimizer extended checks are not enabled."}, status=status.HTTP_400_BAD_REQUEST, ) @@ -542,9 +542,9 @@ def get(self, request: Request, course_id: str): if not has_course_author_access(request.user, course_key): self.permission_denied(request) - if not enable_course_optimizer_extended_report(course_key): + if not enable_course_optimizer_extended_checks(course_key): return JsonResponse( - {"error": "Course optimizer extended report is not enabled."}, + {"error": "Course optimizer extended checks are not enabled."}, status=status.HTTP_400_BAD_REQUEST, ) 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 7e9e5067f0fa..fe76c22676c1 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 @@ -32,7 +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_course_optimizer_extended_report = serializers.SerializerMethodField() + enable_course_optimizer_extended_checks = serializers.SerializerMethodField() enable_unit_expanded_view = serializers.SerializerMethodField() enable_outline_component_creation = serializers.SerializerMethodField() enable_audio_description = serializers.SerializerMethodField() @@ -181,12 +181,12 @@ 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_course_optimizer_extended_report(self, obj): + def get_enable_course_optimizer_extended_checks(self, obj): """ - Method to get the enable_course_optimizer_extended_report waffle flag + Method to get the enable_course_optimizer_extended_checks waffle flag """ course_key = self.get_course_key() - return toggles.enable_course_optimizer_extended_report(course_key) + return toggles.enable_course_optimizer_extended_checks(course_key) def get_enable_unit_expanded_view(self, obj): """ 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 b4779378b786..659bd8621961 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 @@ -39,7 +39,7 @@ class CourseWaffleFlagsViewTest(CourseTestCase): "use_react_markdown_editor": False, "use_video_gallery_flow": False, "enable_course_optimizer_check_prev_run_links": False, - "enable_course_optimizer_extended_report": False, + "enable_course_optimizer_extended_checks": False, "enable_unit_expanded_view": False, "enable_outline_component_creation": False, "enable_audio_description": False, diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 17da0a7d1e0b..4fdde119418b 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -708,28 +708,28 @@ def enable_course_optimizer_check_prev_run_links(course_key): return ENABLE_COURSE_OPTIMIZER_CHECK_PREV_RUN_LINKS.is_enabled(course_key) -# .. toggle_name: contentstore.enable_course_optimizer_extended_report +# .. toggle_name: contentstore.enable_course_optimizer_extended_checks # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False -# .. toggle_description: When enabled, shows the Course Optimizer's extended -# analysis report (time-on-task, learning balance, and LLM-driven -# accessibility/content-quality/pacing findings) alongside the existing -# link-check scan results in the authoring MFE. +# .. toggle_description: When enabled, replaces the Course Optimizer page +# with its extended analysis report (time-on-task, learning balance, and +# LLM-driven accessibility/content-quality/pacing findings) in the +# authoring MFE. # .. toggle_use_cases: temporary # .. toggle_creation_date: 2026-08-13 # .. toggle_target_removal_date: 2027-02-13 -ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.enable_course_optimizer_extended_report', +ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.enable_course_optimizer_extended_checks', __name__, CONTENTSTORE_LOG_PREFIX, ) -def enable_course_optimizer_extended_report(course_key): +def enable_course_optimizer_extended_checks(course_key): """ - Returns a boolean if the Course Optimizer extended report is enabled for the given course. + Returns a boolean if the Course Optimizer extended checks page is enabled for the given course. """ - return ENABLE_COURSE_OPTIMIZER_EXTENDED_REPORT.is_enabled(course_key) + return ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS.is_enabled(course_key) # .. toggle_name: contentstore.enable_unit_expanded_view From dd20076035af8276d50559b29226b59075c5cf68 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Thu, 27 Aug 2026 12:12:15 -0400 Subject: [PATCH 8/9] refactor: move Course Optimizer extended-analysis views to v1 API Splits the new xpert-ai-workflows-driven extended report endpoints (CourseAnalysisReportView, CourseAnalysisReportStatusView) out to v1, nested under a course_optimizer/analysis/{course_id} prefix, while leaving the original link-checking/rerun-link-update endpoints on v0. Co-Authored-By: Claude Sonnet 5 --- .../v0/tests/test_course_optimizer.py | 152 +---------------- .../contentstore/rest_api/v0/urls.py | 10 -- .../rest_api/v0/views/__init__.py | 2 - .../rest_api/v0/views/course_optimizer.py | 144 +--------------- .../contentstore/rest_api/v1/urls.py | 12 ++ .../rest_api/v1/views/__init__.py | 1 + .../rest_api/v1/views/course_optimizer.py | 149 +++++++++++++++++ .../v1/views/tests/test_course_optimizer.py | 155 ++++++++++++++++++ 8 files changed, 321 insertions(+), 304 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py index a83ec3dc5480..cb187a73f296 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py @@ -1,19 +1,11 @@ """ Unit tests for course optimizer """ -from unittest.mock import Mock, patch - -import requests -from django.conf import settings from django.test import TestCase -from edx_toggles.toggles.testutils import override_waffle_flag -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from django.urls import reverse -from rest_framework import status +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase -from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from cms.djangoapps.contentstore.toggles import ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS class TestGetLinkCheckStatus(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): @@ -85,145 +77,3 @@ def test_produces_4xx_when_unexpected_data(self): ''' response = self.make_request(course_id=self.course.id, data={'unexpected_data': 'unexpected_data'}) self.assertIn(response.status_code, range(400, 500)) - - -class CourseAnalysisReportViewTest(CourseTestCase): - """ - Tests for CourseAnalysisReportView, which kicks off a Course Optimizer - extended-analysis run by generating a course export server-side and - handing it to the xpert-ai-workflows backend. - """ - - def setUp(self): - super().setUp() - self.url = reverse( - 'cms.djangoapps.contentstore:v0:course_analysis_report', - kwargs={'course_id': str(self.course.id)}, - ) - self.export_patch = ( - 'cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer.create_export_tarball' - ) - self.backend_post_patch = ( - 'cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer.requests.post' - ) - - def _mock_tarball(self): - tarball = Mock() - tarball.name = '/tmp/whatever.tar.gz' - return tarball - - def test_unauthenticated(self): - self.client.logout() - response = self.client.post(self.url) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_no_course_access(self): - client, _ = self.create_non_staff_authed_user_client() - response = client.post(self.url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_waffle_flag_disabled_returns_400(self): - response = self.client.post(self.url) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) - def test_kicks_off_backend_run(self): - with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: - mock_export.return_value = self._mock_tarball() - mock_post.return_value = Mock( - status_code=202, - json=Mock(return_value={'run_id': 'run-123'}), - ) - response = self.client.post(self.url) - - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(response.json(), {'run_id': 'run-123'}) - self.assertEqual( - mock_post.call_args.kwargs['headers']['X-Api-Key'], - settings.COURSE_ANALYSIS_WORKFLOW_API_KEY, - ) - - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) - def test_backend_unreachable_returns_502(self): - with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: - mock_export.return_value = self._mock_tarball() - mock_post.side_effect = requests.ConnectionError() - response = self.client.post(self.url) - - self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) - - -class CourseAnalysisReportStatusViewTest(CourseTestCase): - """ - Tests for CourseAnalysisReportStatusView, the Studio-side proxy for a - course's latest Course Optimizer extended-analysis run status - (xpert-ai-workflows). - """ - - def setUp(self): - super().setUp() - self.url = reverse( - 'cms.djangoapps.contentstore:v0:course_analysis_report_status', - kwargs={'course_id': str(self.course.id)}, - ) - self.backend_get_patch = ( - 'cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer.requests.get' - ) - - def test_unauthenticated(self): - self.client.logout() - response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_no_course_access(self): - client, _ = self.create_non_staff_authed_user_client() - response = client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_waffle_flag_disabled_returns_400(self): - response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) - def test_proxies_backend_response(self): - with patch(self.backend_get_patch) as mock_get: - mock_get.return_value = Mock( - status_code=200, - json=Mock(return_value={ - 'run_id': 'run-123', 'status': 'COMPLETE', 'report': {}, 'error': None, - }), - ) - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json(), { - 'run_id': 'run-123', 'status': 'COMPLETE', 'report': {}, 'error': None, - }) - - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) - def test_no_runs_yet_returns_404(self): - with patch(self.backend_get_patch) as mock_get: - mock_get.return_value = Mock( - status_code=404, - json=Mock(return_value={'detail': "No runs found for course"}), - ) - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) - def test_backend_unreachable_returns_502(self): - with patch(self.backend_get_patch) as mock_get: - mock_get.side_effect = requests.ConnectionError() - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) - - @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) - def test_produces_404_when_course_does_not_exist(self): - url = reverse( - 'cms.djangoapps.contentstore:v0:course_analysis_report_status', - kwargs={'course_id': 'course-v1:edX+DemoX+Nonexistent_Course'}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/cms/djangoapps/contentstore/rest_api/v0/urls.py b/cms/djangoapps/contentstore/rest_api/v0/urls.py index 7466fe8e6a55..974d1b98a0c4 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v0/urls.py @@ -9,8 +9,6 @@ AdvancedCourseSettingsView, APIHeartBeatView, AuthoringGradingView, - CourseAnalysisReportStatusView, - CourseAnalysisReportView, CourseTabListView, CourseTabReorderView, CourseTabSettingsView, @@ -127,12 +125,4 @@ fr'^rerun_link_update_status/{settings.COURSE_ID_PATTERN}$', RerunLinkUpdateStatusView.as_view(), name='rerun_link_update_status' ), - re_path( - fr'^course_analysis_report/{settings.COURSE_ID_PATTERN}$', - CourseAnalysisReportView.as_view(), name='course_analysis_report' - ), - re_path( - fr'^course_analysis_report_status/{settings.COURSE_ID_PATTERN}$', - CourseAnalysisReportStatusView.as_view(), name='course_analysis_report_status' - ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py index b92d0b8ec705..dc93ce78967a 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py @@ -5,8 +5,6 @@ from .api_heartbeat import APIHeartBeatView from .authoring_grading import AuthoringGradingView from .course_optimizer import ( - CourseAnalysisReportStatusView, - CourseAnalysisReportView, LinkCheckStatusView, LinkCheckView, RerunLinkUpdateStatusView, diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py index 6ce114ddf4cf..26d527e53f4c 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -1,10 +1,6 @@ """API Views for Course Optimizer.""" -import os - import edx_api_doc_tools as apidocs -import requests -from django.conf import settings from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import status @@ -19,19 +15,12 @@ sort_course_sections, ) from cms.djangoapps.contentstore.rest_api.v0.serializers.course_optimizer import ( + CourseRerunLinkUpdateRequestSerializer, CourseRerunLinkUpdateStatusSerializer, LinkCheckSerializer, - CourseRerunLinkUpdateRequestSerializer, -) -from cms.djangoapps.contentstore.tasks import ( - check_broken_links, - create_export_tarball, - update_course_rerun_links, -) -from cms.djangoapps.contentstore.toggles import ( - enable_course_optimizer_check_prev_run_links, - enable_course_optimizer_extended_checks, ) +from cms.djangoapps.contentstore.tasks import check_broken_links, update_course_rerun_links +from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access from common.djangoapps.util.json_request import JsonResponse from openedx.core.lib.api.view_utils import ( @@ -39,7 +28,6 @@ verify_course_exists, view_auth_classes, ) -from xmodule.modulestore.django import modulestore @view_auth_classes(is_authenticated=True) @@ -432,129 +420,3 @@ def get(self, request: Request, course_id: str): data = get_course_link_update_data(request, course_id) serializer = CourseRerunLinkUpdateStatusSerializer(data) return Response(serializer.data) - - -@view_auth_classes(is_authenticated=True) -class CourseAnalysisReportView(DeveloperErrorViewMixin, APIView): - """ - View for kicking off a Course Optimizer extended-analysis run. - """ - - @apidocs.schema( - parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), - ], - responses={ - 202: "Analysis run queued.", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", - 502: "The Course Optimizer extended-report backend is unreachable.", - }, - ) - @verify_course_exists() - def post(self, request: Request, course_id: str): - """ - Generate a fresh export of the course and hand it to the Course - Optimizer extended-report backend (xpert-ai-workflows) to start a - new analysis run. Studio generates the export server-side -- the - browser never uploads anything or talks to that backend directly. - - **Example Request** - - POST /api/contentstore/v0/course_analysis_report/{course_id} - - **Response Values** - ```json - { - "run_id": - } - ``` - """ - course_key = CourseKey.from_string(course_id) - if not has_course_author_access(request.user, course_key): - self.permission_denied(request) - - if not enable_course_optimizer_extended_checks(course_key): - return JsonResponse( - {"error": "Course optimizer extended checks are not enabled."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - course_block = modulestore().get_course(course_key) - tarball = create_export_tarball(course_block, course_key, {}) - try: - tarball.seek(0) - try: - response = requests.post( - f'{settings.COURSE_ANALYSIS_WORKFLOW_URL}/courses/{course_id}/runs', - files={'file': (os.path.basename(tarball.name), tarball, 'application/gzip')}, - headers={'X-Api-Key': settings.COURSE_ANALYSIS_WORKFLOW_API_KEY}, - timeout=settings.COURSE_ANALYSIS_WORKFLOW_REQUEST_TIMEOUT_SECONDS, - ) - except requests.RequestException: - return Response(status=status.HTTP_502_BAD_GATEWAY) - finally: - tarball.close() - - return Response(response.json(), status=response.status_code) - - -@view_auth_classes() -class CourseAnalysisReportStatusView(DeveloperErrorViewMixin, APIView): - """ - View proxying a course's Course Optimizer extended-report status. - - Studio calls the Course Optimizer extended-report backend - (xpert-ai-workflows) server-side and returns its response as-is; the - browser never calls that backend directly. - """ - - @apidocs.schema( - parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), - ], - responses={ - 200: "OK", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The course has no analysis runs yet.", - 502: "The Course Optimizer extended-report backend is unreachable.", - }, - ) - @verify_course_exists() - def get(self, request: Request, course_id: str): - """ - Proxy the status of a course's most recent Course Optimizer - extended-analysis run. - - **Example Request** - - GET /api/contentstore/v0/course_analysis_report_status/{course_id} - - **Response Values** - - The xpert-ai-workflows run-status response, passed through - unchanged: `{run_id, status, report, error}`. A 404 means the - course has no analysis runs yet. - """ - course_key = CourseKey.from_string(course_id) - if not has_course_author_access(request.user, course_key): - self.permission_denied(request) - - if not enable_course_optimizer_extended_checks(course_key): - return JsonResponse( - {"error": "Course optimizer extended checks are not enabled."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - response = requests.get( - f'{settings.COURSE_ANALYSIS_WORKFLOW_URL}/courses/{course_id}/runs/latest', - headers={'X-Api-Key': settings.COURSE_ANALYSIS_WORKFLOW_API_KEY}, - timeout=settings.COURSE_ANALYSIS_WORKFLOW_REQUEST_TIMEOUT_SECONDS, - ) - except requests.RequestException: - return Response(status=status.HTTP_502_BAD_GATEWAY) - - return Response(response.json(), status=response.status_code) diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 8a94f0b0e040..d898d31fa103 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -8,6 +8,8 @@ from .views import ( ContainerChildrenView, ContainerHandlerView, + CourseAnalysisReportStatusView, + CourseAnalysisReportView, CourseCertificatesView, CourseDetailsView, CourseGradingView, @@ -151,6 +153,16 @@ name="unit_components" ), + # Course Optimizer + re_path( + fr'^course_optimizer/analysis/{COURSE_ID_PATTERN}$', + CourseAnalysisReportView.as_view(), name='course_analysis_report' + ), + re_path( + fr'^course_optimizer/analysis/{COURSE_ID_PATTERN}/status$', + CourseAnalysisReportStatusView.as_view(), name='course_analysis_report_status' + ), + # 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 25c0157904e9..250ddf97e714 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -4,6 +4,7 @@ from .certificates import CourseCertificatesView from .course_details import CourseDetailsView from .course_index import ContainerChildrenView, CourseIndexView +from .course_optimizer import CourseAnalysisReportStatusView, CourseAnalysisReportView from .course_rerun import CourseRerunView from .course_team import CourseTeamView from .course_waffle_flags import CourseWaffleFlagsView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py new file mode 100644 index 000000000000..8d890d986291 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py @@ -0,0 +1,149 @@ +"""API Views for the Course Optimizer extended-analysis report.""" + +import os + +import edx_api_doc_tools as apidocs +import requests +from django.conf import settings +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from cms.djangoapps.contentstore.tasks import create_export_tarball +from cms.djangoapps.contentstore.toggles import enable_course_optimizer_extended_checks +from common.djangoapps.student.auth import has_course_author_access +from common.djangoapps.util.json_request import JsonResponse +from openedx.core.lib.api.view_utils import ( + DeveloperErrorViewMixin, + verify_course_exists, + view_auth_classes, +) +from xmodule.modulestore.django import modulestore + + +@view_auth_classes(is_authenticated=True) +class CourseAnalysisReportView(DeveloperErrorViewMixin, APIView): + """ + View for kicking off a Course Optimizer extended-analysis run. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 202: "Analysis run queued.", + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + 502: "The Course Optimizer extended-report backend is unreachable.", + }, + ) + @verify_course_exists() + def post(self, request: Request, course_id: str): + """ + Generate a fresh export of the course and hand it to the Course + Optimizer extended-report backend (xpert-ai-workflows) to start a + new analysis run. Studio generates the export server-side -- the + browser never uploads anything or talks to that backend directly. + + **Example Request** + + POST /api/contentstore/v1/course_optimizer/analysis/{course_id} + + **Response Values** + ```json + { + "run_id": + } + ``` + """ + course_key = CourseKey.from_string(course_id) + if not has_course_author_access(request.user, course_key): + self.permission_denied(request) + + if not enable_course_optimizer_extended_checks(course_key): + return JsonResponse( + {"error": "Course optimizer extended checks are not enabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + course_block = modulestore().get_course(course_key) + tarball = create_export_tarball(course_block, course_key, {}) + try: + tarball.seek(0) + try: + response = requests.post( + f'{settings.COURSE_ANALYSIS_WORKFLOW_URL}/courses/{course_id}/runs', + files={'file': (os.path.basename(tarball.name), tarball, 'application/gzip')}, + headers={'X-Api-Key': settings.COURSE_ANALYSIS_WORKFLOW_API_KEY}, + timeout=settings.COURSE_ANALYSIS_WORKFLOW_REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException: + return Response(status=status.HTTP_502_BAD_GATEWAY) + finally: + tarball.close() + + return Response(response.json(), status=response.status_code) + + +@view_auth_classes() +class CourseAnalysisReportStatusView(DeveloperErrorViewMixin, APIView): + """ + View proxying a course's Course Optimizer extended-report status. + + Studio calls the Course Optimizer extended-report backend + (xpert-ai-workflows) server-side and returns its response as-is; the + browser never calls that backend directly. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: "OK", + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The course has no analysis runs yet.", + 502: "The Course Optimizer extended-report backend is unreachable.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str): + """ + Proxy the status of a course's most recent Course Optimizer + extended-analysis run. + + **Example Request** + + GET /api/contentstore/v1/course_optimizer/analysis/{course_id}/status + + **Response Values** + + The xpert-ai-workflows run-status response, passed through + unchanged: `{run_id, status, report, error}`. A 404 means the + course has no analysis runs yet. + """ + course_key = CourseKey.from_string(course_id) + if not has_course_author_access(request.user, course_key): + self.permission_denied(request) + + if not enable_course_optimizer_extended_checks(course_key): + return JsonResponse( + {"error": "Course optimizer extended checks are not enabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + response = requests.get( + f'{settings.COURSE_ANALYSIS_WORKFLOW_URL}/courses/{course_id}/runs/latest', + headers={'X-Api-Key': settings.COURSE_ANALYSIS_WORKFLOW_API_KEY}, + timeout=settings.COURSE_ANALYSIS_WORKFLOW_REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException: + return Response(status=status.HTTP_502_BAD_GATEWAY) + + return Response(response.json(), status=response.status_code) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py new file mode 100644 index 000000000000..1c9a2110dbc8 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py @@ -0,0 +1,155 @@ +""" +Unit tests for the Course Optimizer extended-analysis report views +""" +from unittest.mock import Mock, patch + +import requests +from django.conf import settings +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag +from rest_framework import status + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.toggles import ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS + + +class CourseAnalysisReportViewTest(CourseTestCase): + """ + Tests for CourseAnalysisReportView, which kicks off a Course Optimizer + extended-analysis run by generating a course export server-side and + handing it to the xpert-ai-workflows backend. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + 'cms.djangoapps.contentstore:v1:course_analysis_report', + kwargs={'course_id': str(self.course.id)}, + ) + self.export_patch = ( + 'cms.djangoapps.contentstore.rest_api.v1.views.course_optimizer.create_export_tarball' + ) + self.backend_post_patch = ( + 'cms.djangoapps.contentstore.rest_api.v1.views.course_optimizer.requests.post' + ) + + def _mock_tarball(self): + tarball = Mock() + tarball.name = '/tmp/whatever.tar.gz' + return tarball + + def test_unauthenticated(self): + self.client.logout() + response = self.client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_no_course_access(self): + client, _ = self.create_non_staff_authed_user_client() + response = client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_waffle_flag_disabled_returns_400(self): + response = self.client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) + def test_kicks_off_backend_run(self): + with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: + mock_export.return_value = self._mock_tarball() + mock_post.return_value = Mock( + status_code=202, + json=Mock(return_value={'run_id': 'run-123'}), + ) + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.json(), {'run_id': 'run-123'}) + self.assertEqual( + mock_post.call_args.kwargs['headers']['X-Api-Key'], + settings.COURSE_ANALYSIS_WORKFLOW_API_KEY, + ) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) + def test_backend_unreachable_returns_502(self): + with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: + mock_export.return_value = self._mock_tarball() + mock_post.side_effect = requests.ConnectionError() + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + + +class CourseAnalysisReportStatusViewTest(CourseTestCase): + """ + Tests for CourseAnalysisReportStatusView, the Studio-side proxy for a + course's latest Course Optimizer extended-analysis run status + (xpert-ai-workflows). + """ + + def setUp(self): + super().setUp() + self.url = reverse( + 'cms.djangoapps.contentstore:v1:course_analysis_report_status', + kwargs={'course_id': str(self.course.id)}, + ) + self.backend_get_patch = ( + 'cms.djangoapps.contentstore.rest_api.v1.views.course_optimizer.requests.get' + ) + + def test_unauthenticated(self): + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_no_course_access(self): + client, _ = self.create_non_staff_authed_user_client() + response = client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_waffle_flag_disabled_returns_400(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) + def test_proxies_backend_response(self): + with patch(self.backend_get_patch) as mock_get: + mock_get.return_value = Mock( + status_code=200, + json=Mock(return_value={ + 'run_id': 'run-123', 'status': 'COMPLETE', 'report': {}, 'error': None, + }), + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), { + 'run_id': 'run-123', 'status': 'COMPLETE', 'report': {}, 'error': None, + }) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) + def test_no_runs_yet_returns_404(self): + with patch(self.backend_get_patch) as mock_get: + mock_get.return_value = Mock( + status_code=404, + json=Mock(return_value={'detail': "No runs found for course"}), + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) + def test_backend_unreachable_returns_502(self): + with patch(self.backend_get_patch) as mock_get: + mock_get.side_effect = requests.ConnectionError() + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) + def test_produces_404_when_course_does_not_exist(self): + url = reverse( + 'cms.djangoapps.contentstore:v1:course_analysis_report_status', + kwargs={'course_id': 'course-v1:edX+DemoX+Nonexistent_Course'}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) From 4d4241a10872a8d7dd1aa379e156ec98af2332cc Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Thu, 27 Aug 2026 15:05:33 -0400 Subject: [PATCH 9/9] fix: guard against malformed JSON from xpert-ai-workflows and allow devstack overrides Addresses PR review feedback: response.json() on the extended-report proxy views now returns 502 instead of an unhandled 500 when the backend returns a non-JSON body, and the devstack URL/API key default to ENV_TOKENS overrides like other devstack secrets. Co-Authored-By: Claude Sonnet 5 --- .../rest_api/v1/views/course_optimizer.py | 14 +++++++++-- .../v1/views/tests/test_course_optimizer.py | 23 +++++++++++++++++++ cms/envs/devstack.py | 5 ++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py index 8d890d986291..b97b83acf669 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py @@ -86,7 +86,12 @@ def post(self, request: Request, course_id: str): finally: tarball.close() - return Response(response.json(), status=response.status_code) + try: + response_data = response.json() + except ValueError: + return Response(status=status.HTTP_502_BAD_GATEWAY) + + return Response(response_data, status=response.status_code) @view_auth_classes() @@ -146,4 +151,9 @@ def get(self, request: Request, course_id: str): except requests.RequestException: return Response(status=status.HTTP_502_BAD_GATEWAY) - return Response(response.json(), status=response.status_code) + try: + response_data = response.json() + except ValueError: + return Response(status=status.HTTP_502_BAD_GATEWAY) + + return Response(response_data, status=response.status_code) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py index 1c9a2110dbc8..f4b9b2b9ead7 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py @@ -78,6 +78,18 @@ def test_backend_unreachable_returns_502(self): self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) + def test_backend_returns_invalid_json_returns_502(self): + with patch(self.export_patch) as mock_export, patch(self.backend_post_patch) as mock_post: + mock_export.return_value = self._mock_tarball() + mock_post.return_value = Mock( + status_code=202, + json=Mock(side_effect=ValueError()), + ) + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + class CourseAnalysisReportStatusViewTest(CourseTestCase): """ @@ -145,6 +157,17 @@ def test_backend_unreachable_returns_502(self): self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) + def test_backend_returns_invalid_json_returns_502(self): + with patch(self.backend_get_patch) as mock_get: + mock_get.return_value = Mock( + status_code=200, + json=Mock(side_effect=ValueError()), + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + @override_waffle_flag(ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS, True) def test_produces_404_when_course_does_not_exist(self): url = reverse( diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 0ca940ab8a16..2031819b90de 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -295,8 +295,9 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing # xpert-ai-workflows running on the host machine (not in the devstack docker # network), reached via Docker's host-gateway alias. Shared secret must match # that service's own EDX_STUDIO_API_KEY. -COURSE_ANALYSIS_WORKFLOW_URL = 'http://host.docker.internal:19090' -COURSE_ANALYSIS_WORKFLOW_API_KEY = 'this_is_a_test_api_key' # matches xpert-ai-workflows' local .env +COURSE_ANALYSIS_WORKFLOW_URL = ENV_TOKENS.get('COURSE_ANALYSIS_WORKFLOW_URL', 'http://host.docker.internal:19090') +# matches xpert-ai-workflows' local .env by default +COURSE_ANALYSIS_WORKFLOW_API_KEY = ENV_TOKENS.get('COURSE_ANALYSIS_WORKFLOW_API_KEY', 'this_is_a_test_api_key') ############################ CSRF ##################################