Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 159 additions & 4 deletions lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import UsageKey
from pytz import UTC
from rest_framework.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_200_OK, HTTP_404_NOT_FOUND
from testfixtures import LogCapture
from rest_framework.test import APITestCase

from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
Expand All @@ -54,7 +56,7 @@
CourseBetaTesterRole,
CourseDataResearcherRole,
CourseFinanceAdminRole,
CourseInstructorRole
CourseInstructorRole, CourseStaffRole
)
from common.djangoapps.student.tests.factories import (
BetaTesterFactory,
Expand Down Expand Up @@ -88,6 +90,8 @@
from lms.djangoapps.instructor_task.data import InstructorTaskTypes
from lms.djangoapps.instructor_task.models import InstructorTask, InstructorTaskSchedule
from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.course_date_signals.handlers import extract_dates
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA, Role
Expand Down Expand Up @@ -160,6 +164,7 @@
'get_issued_certificates',
'instructor_api_v1:list_instructor_tasks',
'instructor_api_v1:list_report_downloads',
'instructor_api_v1:course_modes_list',
}
INSTRUCTOR_POST_ENDPOINTS = {
'add_users_to_cohorts',
Expand Down Expand Up @@ -2694,9 +2699,9 @@ def test_get_problem_responses_successful(self, endpoint, post_data):
response = self.client.post(url, post_data, content_type="application/json")
res_json = json.loads(response.content.decode('utf-8'))
assert 'status' in res_json
status = res_json['status']
assert 'is being created' in status
assert 'already in progress' not in status
state = res_json['status']
assert 'is being created' in state
assert 'already in progress' not in state
assert 'task_id' in res_json

@valid_problem_location
Expand Down Expand Up @@ -5060,3 +5065,153 @@ def test_end_points_with_oauth_with_permissions(self):
Verify the endpoint using JWT authentication with permissions.
"""
self.run_endpoint_tests(expected_status=200, add_role=True, use_jwt=True)


@ddt.ddt
class CourseModeListViewTest(SharedModuleStoreTestCase, APITestCase):
"""
Tests for the CourseModeListView API endpoint.
"""

def setUp(self):
"""Set up test data."""
super().setUp()
self.course = CourseFactory.create()
self.course_overview = CourseOverviewFactory.create(
id=self.course.id,
org='org'
)
self.url = reverse('instructor_api_v1:course_modes_list', kwargs={'course_id': str(self.course.id)})

# Create users
self.instructor_user = UserFactory.create()
self.staff_user = UserFactory.create()
self.student_user = UserFactory.create()
self.anonymous_user = None

# Assign roles
CourseInstructorRole(self.course.id).add_users(self.instructor_user)
CourseStaffRole(self.course.id).add_users(self.staff_user)

self.mode_audit = None
self.exp_dt = None
self.mode_verified = None
self.status = None

def _create_test_modes(self):
"""Helper to create standard modes for the test course."""
self.mode_audit = CourseModeFactory.create(
course=self.course_overview,
mode_slug='audit',
mode_display_name='Audit',
min_price=0,
sku='AUDIT-SKU'
)

self.exp_dt = (datetime.datetime.now(UTC) + datetime.timedelta(days=30)).replace(microsecond=0)
self.mode_verified = CourseModeFactory.create(
course=self.course_overview,
mode_slug='verified',
mode_display_name='Verified',
min_price=99,
expiration_datetime=self.exp_dt,
expiration_datetime_is_explicit=True,
sku='VERIFIED-SKU',
android_sku='ANDROID-SKU',
ios_sku='IOS-SKU',
bulk_sku='BULK-SKU'
)

# --- Authentication and Permission Tests ---

def test_anonymous_user_forbidden(self):
"""Verify anonymous users receive 401 Unauthorized."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED)

def test_student_user_forbidden(self):
"""Verify users without instructor/staff access receive 403 Forbidden."""
self.client.force_authenticate(user=self.student_user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

def test_course_not_found(self):
"""Verify a 404 is returned for a non-existent course."""
self.client.force_authenticate(user=self.instructor_user)
bad_url = reverse(
'instructor_api_v1:course_modes_list',
kwargs={'course_id': 'course-v1:Missing+Org+Run'}
)
CourseOverview.objects.filter(id='course-v1:Missing+Org+Run').delete()
response = self.client.get(bad_url)
self.assertEqual(response.status_code, HTTP_404_NOT_FOUND)

@ddt.data('instructor_user', 'staff_user')
def test_authorized_user_success(self, user_type):
"""Verify instructors and staff receive 200 OK."""
user = getattr(self, user_type)
self.client.force_authenticate(user=user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, HTTP_200_OK)

def test_returns_all_course_modes(self):
"""
Verify the API returns all modes associated with the course.
"""
self._create_test_modes()
self.client.force_authenticate(user=self.instructor_user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, HTTP_200_OK)

data = response.json()
self.assertIn('modes', data)
self.assertEqual(len(data['modes']), 2)

# Sort by slug to ensure consistent order for comparison
modes = sorted(data['modes'], key=lambda x: x['mode_slug'])

# Test audit mode
self.assertEqual(modes[0]['mode_slug'], 'audit')
self.assertEqual(modes[0]['mode_display_name'], 'Audit')
self.assertEqual(modes[0]['min_price'], 0)
self.assertEqual(modes[0]['currency'], 'usd')
self.assertIsNone(modes[0]['expiration_datetime'])
self.assertEqual(modes[0]['sku'], 'AUDIT-SKU')
self.assertIsNone(modes[0]['bulk_sku'])

# Test verified mode
self.assertEqual(modes[1]['mode_slug'], 'verified')
self.assertEqual(modes[1]['mode_display_name'], 'Verified')
self.assertEqual(modes[1]['min_price'], 99)
self.assertEqual(
modes[1]['expiration_datetime'],
self.exp_dt.isoformat().replace('+00:00', 'Z')
)
self.assertEqual(modes[1]['sku'], 'VERIFIED-SKU')
self.assertEqual(modes[1]['android_sku'], 'ANDROID-SKU')
self.assertEqual(modes[1]['ios_sku'], 'IOS-SKU')
self.assertEqual(modes[1]['bulk_sku'], 'BULK-SKU')

def test_returns_expired_mode(self):
"""
The API should include expired modes for instructors.
"""
exp_dt = (datetime.datetime.now(UTC) - datetime.timedelta(days=10)).replace(microsecond=0)
CourseModeFactory.create(
course=self.course_overview,
mode_slug='verified',
expiration_datetime=exp_dt,
expiration_datetime_is_explicit=True,
)

self.client.force_authenticate(user=self.instructor_user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, HTTP_200_OK)

data = response.json()
self.assertEqual(len(data['modes']), 1)
self.assertEqual(data['modes'][0]['mode_slug'], 'verified')
self.assertEqual(
data['modes'][0]['expiration_datetime'],
exp_dt.isoformat().replace('+00:00', 'Z')
)
90 changes: 87 additions & 3 deletions lms/djangoapps/instructor/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_name
from rest_framework.generics import GenericAPIView
from rest_framework.exceptions import MethodNotAllowed
from rest_framework import serializers, status # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.permissions import IsAdminUser, IsAuthenticated, BasePermission # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -118,7 +119,7 @@
RescoreEntranceExamSerializer,
OverrideProblemScoreSerializer,
StudentsUpdateEnrollmentSerializer,
ResetEntranceExamAttemptsSerializer
ResetEntranceExamAttemptsSerializer, CourseModeListSerializer
)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
Expand Down Expand Up @@ -147,6 +148,7 @@
strip_if_string,
)
from .. import permissions
from ..permissions import VIEW_DASHBOARD

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -264,6 +266,7 @@ def wrapped(request, course_id):
return func(request, course_id)
else:
return HttpResponseForbidden()

return wrapped


Expand Down Expand Up @@ -4232,7 +4235,8 @@ def re_validate_certificate(request, course_key, generated_certificate, student)

certificate_invalidation = certs_api.get_certificate_invalidation_entry(generated_certificate)
if not certificate_invalidation:
raise ValueError(_("Certificate Invalidation does not exist, Please refresh the page and try again.")) # lint-amnesty, pylint: disable=raise-missing-from
raise ValueError(
_("Certificate Invalidation does not exist, Please refresh the page and try again.")) # lint-amnesty, pylint: disable=raise-missing-from

certificate_invalidation.deactivate()

Expand Down Expand Up @@ -4288,7 +4292,7 @@ def _get_certificate_for_user(course_key, student):
raise ValueError(_(
"The student {student} does not have certificate for the course {course}. Kindly verify student "
"username/email and the selected course are correct and try again.").format(
student=student.username, course=course_key.course)
student=student.username, course=course_key.course)
)

return certificate
Expand Down Expand Up @@ -4334,3 +4338,83 @@ def _get_branded_email_template(course_overview):
template_name = template_name.get(course_overview.display_org_with_default)

return template_name


class CourseModeListView(GenericAPIView):
"""
Retrieves the available enrollment modes (e.g., audit, verified) for a specific course.
Requires instructor or staff access to the course.
:param course_id: (Query Param) The unique identifier (course key) for the course.
:type course_id: string
**Example Request:**
.. code-block:: http
GET /api/instructor/courses/{COURSE_ID_PATTERN}/modes
**Success Response (200 OK):**
Returns a JSON object containing a list of the course's enrollment modes.
.. code-block:: json
{
"modes": [
{
"mode_slug": "audit",
"mode_display_name": "Audit Track",
"min_price": 0,
"currency": "USD",
"expiration_datetime": null,
"description": "Access the course materials for free, but without a certificate.",
"sku": null,
"android_sku": null,
"ios_sku": null,
"bulk_sku": null
},
{
"mode_slug": "verified",
"mode_display_name": "Verified Track",
"min_price": 49,
"currency": "USD",
"expiration_datetime": null,
"description": "Access all materials, graded assignments, and earn a verified certificate.",
"sku": null,
"android_sku": null,
"ios_sku": null,
"bulk_sku": null
}
]
}
:raises 401 Unauthorized: User is not authenticated.
:raises 403 Forbidden: User lacks instructor or staff permissions for the course.
:raises 404 Not Found: The specified `course_key` does not exist.
"""
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_name = VIEW_DASHBOARD

@apidocs.schema(
parameters=[
apidocs.string_parameter(
'course_id',
apidocs.ParameterLocation.PATH,
description="Course key for the course.",
),
],
responses={
200: CourseModeListSerializer,
401: "The requesting user is not authenticated.",
403: "The requesting user lacks instructor access to the course.",
404: "The requested course does not exist.",
},
)
def get(self, request, course_id):
"""
Handles the GET request for course modes.

Args:
request (Request): The DRF request object.
course_id (CourseKey): The course key, parsed from the URL.

Returns:
Response: A DRF Response object containing the serialized
course modes or an error.
"""
all_modes = CourseMode.objects.filter(course_id=course_id)

serializer = CourseModeListSerializer(instance={'modes': all_modes})
return Response(serializer.data, status=status.HTTP_200_OK)
7 changes: 6 additions & 1 deletion lms/djangoapps/instructor/views/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"""
Instructor API endpoint urls.
"""

from django.urls import path, re_path

from lms.djangoapps.instructor.views import api, gradebook_api
Expand All @@ -18,6 +17,11 @@
re_path(rf'^reports/{COURSE_ID_PATTERN}$', api.ReportDownloads.as_view(), name='list_report_downloads', ),
re_path(rf'^reports/{COURSE_ID_PATTERN}/generate/problem_responses$', api.ProblemResponseReportInitiate.as_view(),
name='generate_problem_responses', ),
re_path(
rf'^courses/{COURSE_ID_PATTERN}/modes$',
api.CourseModeListView.as_view(),
name='course_modes_list'
),
]

urlpatterns = [
Expand Down Expand Up @@ -100,4 +104,5 @@
api.CertificateInvalidationView.as_view(),
name='certificate_invalidation_view'
),

]
Loading
Loading