Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 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,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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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_checks = 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_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
Expand Down
12 changes: 12 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from .views import (
ContainerChildrenView,
ContainerHandlerView,
CourseAnalysisReportStatusView,
CourseAnalysisReportView,
CourseCertificatesView,
CourseDetailsView,
CourseGradingView,
Expand Down Expand Up @@ -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'
),
Comment thread
nsprenkle marked this conversation as resolved.
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
]
1 change: 1 addition & 0 deletions cms/djangoapps/contentstore/rest_api/v1/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/views/course_optimizer.py
Original file line number Diff line number Diff line change
@@ -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": <string>
}
```
"""
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:
Comment thread
nsprenkle marked this conversation as resolved.
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)
Comment thread
nsprenkle marked this conversation as resolved.
Outdated


@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)
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading