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..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 @@ -2,8 +2,8 @@ Unit tests for course optimizer """ from django.test import TestCase -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from django.urls import reverse +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py index 5714754b191e..dc93ce78967a 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py @@ -4,6 +4,11 @@ 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 ( + 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..26d527e53f4c 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -15,9 +15,9 @@ 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, update_course_rerun_links from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links 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..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,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_checks = 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_checks(self, obj): + """ + Method to get the enable_course_optimizer_extended_checks waffle flag + """ + course_key = self.get_course_key() + return toggles.enable_course_optimizer_extended_checks(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/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..b97b83acf669 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py @@ -0,0 +1,159 @@ +"""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() + + 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() +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) + + 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 new file mode 100644 index 000000000000..f4b9b2b9ead7 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_optimizer.py @@ -0,0 +1,178 @@ +""" +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) + + @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): + """ + 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_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( + '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) 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..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,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_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 ca5e02adc78f..4fdde119418b 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_checks +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. 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_CHECKS = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.enable_course_optimizer_extended_checks', + __name__, + CONTENTSTORE_LOG_PREFIX, +) + + +def enable_course_optimizer_extended_checks(course_key): + """ + Returns a boolean if the Course Optimizer extended checks page is enabled for the given course. + """ + return ENABLE_COURSE_OPTIMIZER_EXTENDED_CHECKS.is_enabled(course_key) + + # .. toggle_name: contentstore.enable_unit_expanded_view # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False diff --git a/cms/envs/common.py b/cms/envs/common.py index d330ad909dfa..7a8bde7dce96 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1782,6 +1782,25 @@ 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: 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'] # .. setting_description: List of block types that are ready/enabled to be created/used diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index d683f48708f1..2031819b90de 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -291,6 +291,14 @@ 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 = 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 ################################## # MFEs that will call this service in devstack