Skip to content

Commit 6d0bf82

Browse files
feat: created api to get course modes in instructor app
fix: resolved linter errors chore: added docs to api fix: removed redundant code fix: removed redundant code
1 parent 7f8ba45 commit 6d0bf82

File tree

4 files changed

+301
-8
lines changed

4 files changed

+301
-8
lines changed

lms/djangoapps/instructor/tests/test_api.py

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
from opaque_keys.edx.keys import CourseKey
3131
from opaque_keys.edx.locator import UsageKey
3232
from pytz import UTC
33+
from rest_framework.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_200_OK, HTTP_404_NOT_FOUND
3334
from testfixtures import LogCapture
35+
from rest_framework.test import APITestCase
3436

3537
from common.djangoapps.course_modes.models import CourseMode
3638
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
@@ -54,7 +56,7 @@
5456
CourseBetaTesterRole,
5557
CourseDataResearcherRole,
5658
CourseFinanceAdminRole,
57-
CourseInstructorRole
59+
CourseInstructorRole, CourseStaffRole
5860
)
5961
from common.djangoapps.student.tests.factories import (
6062
BetaTesterFactory,
@@ -88,6 +90,8 @@
8890
from lms.djangoapps.instructor_task.data import InstructorTaskTypes
8991
from lms.djangoapps.instructor_task.models import InstructorTask, InstructorTaskSchedule
9092
from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory
93+
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
94+
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
9195
from openedx.core.djangoapps.course_date_signals.handlers import extract_dates
9296
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
9397
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA, Role
@@ -160,6 +164,7 @@
160164
'get_issued_certificates',
161165
'instructor_api_v1:list_instructor_tasks',
162166
'instructor_api_v1:list_report_downloads',
167+
'instructor_api_v1:course_modes_list',
163168
}
164169
INSTRUCTOR_POST_ENDPOINTS = {
165170
'add_users_to_cohorts',
@@ -2694,9 +2699,9 @@ def test_get_problem_responses_successful(self, endpoint, post_data):
26942699
response = self.client.post(url, post_data, content_type="application/json")
26952700
res_json = json.loads(response.content.decode('utf-8'))
26962701
assert 'status' in res_json
2697-
status = res_json['status']
2698-
assert 'is being created' in status
2699-
assert 'already in progress' not in status
2702+
state = res_json['status']
2703+
assert 'is being created' in state
2704+
assert 'already in progress' not in state
27002705
assert 'task_id' in res_json
27012706

27022707
@valid_problem_location
@@ -5060,3 +5065,153 @@ def test_end_points_with_oauth_with_permissions(self):
50605065
Verify the endpoint using JWT authentication with permissions.
50615066
"""
50625067
self.run_endpoint_tests(expected_status=200, add_role=True, use_jwt=True)
5068+
5069+
5070+
@ddt.ddt
5071+
class CourseModeListViewTest(SharedModuleStoreTestCase, APITestCase):
5072+
"""
5073+
Tests for the CourseModeListView API endpoint.
5074+
"""
5075+
5076+
def setUp(self):
5077+
"""Set up test data."""
5078+
super().setUp()
5079+
self.course = CourseFactory.create()
5080+
self.course_overview = CourseOverviewFactory.create(
5081+
id=self.course.id,
5082+
org='org'
5083+
)
5084+
self.url = reverse('instructor_api_v1:course_modes_list', kwargs={'course_id': str(self.course.id)})
5085+
5086+
# Create users
5087+
self.instructor_user = UserFactory.create()
5088+
self.staff_user = UserFactory.create()
5089+
self.student_user = UserFactory.create()
5090+
self.anonymous_user = None
5091+
5092+
# Assign roles
5093+
CourseInstructorRole(self.course.id).add_users(self.instructor_user)
5094+
CourseStaffRole(self.course.id).add_users(self.staff_user)
5095+
5096+
self.mode_audit = None
5097+
self.exp_dt = None
5098+
self.mode_verified = None
5099+
self.status = None
5100+
5101+
def _create_test_modes(self):
5102+
"""Helper to create standard modes for the test course."""
5103+
self.mode_audit = CourseModeFactory.create(
5104+
course=self.course_overview,
5105+
mode_slug='audit',
5106+
mode_display_name='Audit',
5107+
min_price=0,
5108+
sku='AUDIT-SKU'
5109+
)
5110+
5111+
self.exp_dt = (datetime.datetime.now(UTC) + datetime.timedelta(days=30)).replace(microsecond=0)
5112+
self.mode_verified = CourseModeFactory.create(
5113+
course=self.course_overview,
5114+
mode_slug='verified',
5115+
mode_display_name='Verified',
5116+
min_price=99,
5117+
expiration_datetime=self.exp_dt,
5118+
expiration_datetime_is_explicit=True,
5119+
sku='VERIFIED-SKU',
5120+
android_sku='ANDROID-SKU',
5121+
ios_sku='IOS-SKU',
5122+
bulk_sku='BULK-SKU'
5123+
)
5124+
5125+
# --- Authentication and Permission Tests ---
5126+
5127+
def test_anonymous_user_forbidden(self):
5128+
"""Verify anonymous users receive 401 Unauthorized."""
5129+
response = self.client.get(self.url)
5130+
self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED)
5131+
5132+
def test_student_user_forbidden(self):
5133+
"""Verify users without instructor/staff access receive 403 Forbidden."""
5134+
self.client.force_authenticate(user=self.student_user)
5135+
response = self.client.get(self.url)
5136+
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
5137+
5138+
def test_course_not_found(self):
5139+
"""Verify a 404 is returned for a non-existent course."""
5140+
self.client.force_authenticate(user=self.instructor_user)
5141+
bad_url = reverse(
5142+
'instructor_api_v1:course_modes_list',
5143+
kwargs={'course_id': 'course-v1:Missing+Org+Run'}
5144+
)
5145+
CourseOverview.objects.filter(id=self.course.id).delete()
5146+
response = self.client.get(bad_url)
5147+
self.assertEqual(response.status_code, HTTP_404_NOT_FOUND)
5148+
5149+
@ddt.data('instructor_user', 'staff_user')
5150+
def test_authorized_user_success(self, user_type):
5151+
"""Verify instructors and staff receive 200 OK."""
5152+
user = getattr(self, user_type)
5153+
self.client.force_authenticate(user=user)
5154+
response = self.client.get(self.url)
5155+
self.assertEqual(response.status_code, HTTP_200_OK)
5156+
5157+
def test_returns_all_course_modes(self):
5158+
"""
5159+
Verify the API returns all modes associated with the course.
5160+
"""
5161+
self._create_test_modes()
5162+
self.client.force_authenticate(user=self.instructor_user)
5163+
response = self.client.get(self.url)
5164+
self.assertEqual(response.status_code, HTTP_200_OK)
5165+
5166+
data = response.json()
5167+
self.assertIn('modes', data)
5168+
self.assertEqual(len(data['modes']), 2)
5169+
5170+
# Sort by slug to ensure consistent order for comparison
5171+
modes = sorted(data['modes'], key=lambda x: x['mode_slug'])
5172+
5173+
# Test audit mode
5174+
self.assertEqual(modes[0]['mode_slug'], 'audit')
5175+
self.assertEqual(modes[0]['mode_display_name'], 'Audit')
5176+
self.assertEqual(modes[0]['min_price'], 0)
5177+
self.assertEqual(modes[0]['currency'], 'usd')
5178+
self.assertIsNone(modes[0]['expiration_datetime'])
5179+
self.assertEqual(modes[0]['sku'], 'AUDIT-SKU')
5180+
self.assertIsNone(modes[0]['bulk_sku'])
5181+
5182+
# Test verified mode
5183+
self.assertEqual(modes[1]['mode_slug'], 'verified')
5184+
self.assertEqual(modes[1]['mode_display_name'], 'Verified')
5185+
self.assertEqual(modes[1]['min_price'], 99)
5186+
self.assertEqual(
5187+
modes[1]['expiration_datetime'],
5188+
self.exp_dt.isoformat().replace('+00:00', 'Z')
5189+
)
5190+
self.assertEqual(modes[1]['sku'], 'VERIFIED-SKU')
5191+
self.assertEqual(modes[1]['android_sku'], 'ANDROID-SKU')
5192+
self.assertEqual(modes[1]['ios_sku'], 'IOS-SKU')
5193+
self.assertEqual(modes[1]['bulk_sku'], 'BULK-SKU')
5194+
5195+
def test_returns_expired_mode(self):
5196+
"""
5197+
The API should include expired modes for instructors.
5198+
"""
5199+
exp_dt = (datetime.datetime.now(UTC) - datetime.timedelta(days=10)).replace(microsecond=0)
5200+
CourseModeFactory.create(
5201+
course=self.course_overview,
5202+
mode_slug='verified',
5203+
expiration_datetime=exp_dt,
5204+
expiration_datetime_is_explicit=True,
5205+
)
5206+
5207+
self.client.force_authenticate(user=self.instructor_user)
5208+
response = self.client.get(self.url)
5209+
self.assertEqual(response.status_code, HTTP_200_OK)
5210+
5211+
data = response.json()
5212+
self.assertEqual(len(data['modes']), 1)
5213+
self.assertEqual(data['modes'][0]['mode_slug'], 'verified')
5214+
self.assertEqual(
5215+
data['modes'][0]['expiration_datetime'],
5216+
exp_dt.isoformat().replace('+00:00', 'Z')
5217+
)

lms/djangoapps/instructor/views/api.py

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from edx_when.api import get_date_for_block
3737
from opaque_keys import InvalidKeyError
3838
from opaque_keys.edx.keys import CourseKey, UsageKey
39+
from rest_framework.generics import GenericAPIView
40+
3941
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_name
4042
from rest_framework.exceptions import MethodNotAllowed
4143
from rest_framework import serializers, status # lint-amnesty, pylint: disable=wrong-import-order
@@ -118,7 +120,7 @@
118120
RescoreEntranceExamSerializer,
119121
OverrideProblemScoreSerializer,
120122
StudentsUpdateEnrollmentSerializer,
121-
ResetEntranceExamAttemptsSerializer
123+
ResetEntranceExamAttemptsSerializer, CourseModeListSerializer, CourseModeSerializer
122124
)
123125
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
124126
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
@@ -147,6 +149,7 @@
147149
strip_if_string,
148150
)
149151
from .. import permissions
152+
from ..permissions import VIEW_DASHBOARD
150153

151154
log = logging.getLogger(__name__)
152155

@@ -264,6 +267,7 @@ def wrapped(request, course_id):
264267
return func(request, course_id)
265268
else:
266269
return HttpResponseForbidden()
270+
267271
return wrapped
268272

269273

@@ -4232,7 +4236,8 @@ def re_validate_certificate(request, course_key, generated_certificate, student)
42324236

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

42374242
certificate_invalidation.deactivate()
42384243

@@ -4288,7 +4293,7 @@ def _get_certificate_for_user(course_key, student):
42884293
raise ValueError(_(
42894294
"The student {student} does not have certificate for the course {course}. Kindly verify student "
42904295
"username/email and the selected course are correct and try again.").format(
4291-
student=student.username, course=course_key.course)
4296+
student=student.username, course=course_key.course)
42924297
)
42934298

42944299
return certificate
@@ -4334,3 +4339,96 @@ def _get_branded_email_template(course_overview):
43344339
template_name = template_name.get(course_overview.display_org_with_default)
43354340

43364341
return template_name
4342+
4343+
4344+
class CourseModeListView(GenericAPIView):
4345+
"""
4346+
Retrieves the available enrollment modes (e.g., audit, verified) for a specific course.
4347+
4348+
Requires instructor or staff access to the course.
4349+
4350+
:param course_id: (Query Param) The unique identifier (course key) for the course.
4351+
:type course_id: string
4352+
4353+
**Example Request:**
4354+
4355+
.. code-block:: http
4356+
4357+
GET /api/instructor/course/mode/?course_key=course-v1:MyOrg+CS101+2025
4358+
4359+
**Success Response (200 OK):**
4360+
4361+
Returns a JSON object containing a list of the course's enrollment modes.
4362+
4363+
.. code-block:: json
4364+
4365+
{
4366+
"modes": [
4367+
{
4368+
"mode_slug": "audit",
4369+
"mode_display_name": "Audit Track",
4370+
"min_price": 0,
4371+
"currency": "USD",
4372+
"expiration_datetime": null,
4373+
"description": "Access the course materials for free, but without a certificate.",
4374+
"sku": null,
4375+
"android_sku": null,
4376+
"ios_sku": null,
4377+
"bulk_sku": null
4378+
},
4379+
{
4380+
"mode_slug": "verified",
4381+
"mode_display_name": "Verified Track",
4382+
"min_price": 49,
4383+
"currency": "USD",
4384+
"expiration_datetime": null,
4385+
"description": "Access all materials, graded assignments, and earn a verified certificate.",
4386+
"sku": null,
4387+
"android_sku": null,
4388+
"ios_sku": null,
4389+
"bulk_sku": null
4390+
}
4391+
]
4392+
}
4393+
4394+
:raises 401 Unauthorized: User is not authenticated.
4395+
:raises 403 Forbidden: User lacks instructor or staff permissions for the course.
4396+
:raises 404 Not Found: The specified `course_key` does not exist.
4397+
"""
4398+
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
4399+
permission_name = VIEW_DASHBOARD
4400+
4401+
@apidocs.schema(
4402+
parameters=[
4403+
apidocs.string_parameter(
4404+
'course_id',
4405+
apidocs.ParameterLocation.PATH,
4406+
description="Course key for the course.",
4407+
),
4408+
],
4409+
responses={
4410+
200: CourseModeListSerializer,
4411+
401: "The requesting user is not authenticated.",
4412+
403: "The requesting user lacks instructor access to the course.",
4413+
404: "The requested course does not exist.",
4414+
},
4415+
)
4416+
def get(self, request, course_id):
4417+
"""
4418+
Handles the GET request for course modes.
4419+
4420+
Args:
4421+
request (Request): The DRF request object.
4422+
course_id (CourseKey): The course key, parsed from the URL.
4423+
4424+
Returns:
4425+
Response: A DRF Response object containing the serialized
4426+
course modes or an error.
4427+
"""
4428+
all_modes = CourseMode.objects.filter(course_id=course_id)
4429+
4430+
serializer = CourseModeSerializer(all_modes, many=True)
4431+
modes_data = serializer.data
4432+
response_data = {'modes': modes_data}
4433+
response_serializer = CourseModeListSerializer(instance=response_data)
4434+
return Response(response_serializer.data, status=status.HTTP_200_OK)

lms/djangoapps/instructor/views/api_urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
"""
33
Instructor API endpoint urls.
44
"""
5-
65
from django.urls import path, re_path
76

87
from lms.djangoapps.instructor.views import api, gradebook_api
@@ -18,6 +17,11 @@
1817
re_path(rf'^reports/{COURSE_ID_PATTERN}$', api.ReportDownloads.as_view(), name='list_report_downloads', ),
1918
re_path(rf'^reports/{COURSE_ID_PATTERN}/generate/problem_responses$', api.ProblemResponseReportInitiate.as_view(),
2019
name='generate_problem_responses', ),
20+
re_path(
21+
f'courses/{COURSE_ID_PATTERN}/modes',
22+
api.CourseModeListView.as_view(),
23+
name='course_modes_list'
24+
)
2125
]
2226

2327
urlpatterns = [
@@ -100,4 +104,5 @@
100104
api.CertificateInvalidationView.as_view(),
101105
name='certificate_invalidation_view'
102106
),
107+
103108
]

0 commit comments

Comments
 (0)