Skip to content
126 changes: 126 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
10 changes: 10 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v0/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
AdvancedCourseSettingsView,
APIHeartBeatView,
AuthoringGradingView,
CourseAnalysisReportStatusView,
CourseAnalysisReportView,
CourseTabListView,
CourseTabReorderView,
CourseTabSettingsView,
Expand Down Expand Up @@ -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'
),
]
9 changes: 8 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v0/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
124 changes: 123 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -28,6 +36,7 @@
verify_course_exists,
view_auth_classes,
)
from xmodule.modulestore.django import modulestore


@view_auth_classes(is_authenticated=True)
Expand Down Expand Up @@ -420,3 +429,116 @@ 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": <string>
}
```
"""
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, {})
Comment thread
nsprenkle marked this conversation as resolved.
Outdated
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.",
},
)
def get(self, request: Request, course_id: str):
Comment thread
nsprenkle marked this conversation as resolved.
Outdated
"""
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions cms/djangoapps/contentstore/toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading