Skip to content

Commit 43cc724

Browse files
feat: created api to get course modes in instructor app
fix: resolved linter errors chore: added docs to api feat: added update mode price api for instructors fix: resolved linter errors fix: resolved linter errors fix: removed redundant code
1 parent 6d0bf82 commit 43cc724

File tree

5 files changed

+325
-4
lines changed

5 files changed

+325
-4
lines changed

lms/djangoapps/instructor/permissions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from rest_framework.exceptions import PermissionDenied
88
from rest_framework.permissions import BasePermission
99

10+
from common.djangoapps.student.roles import GlobalStaff
1011
from lms.djangoapps.courseware.access import has_access
1112
from lms.djangoapps.courseware.rules import HasAccessRule, HasRolesRule
1213
from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access
@@ -83,6 +84,8 @@
8384
class InstructorPermission(BasePermission):
8485
"""Generic permissions"""
8586
def has_permission(self, request, view):
87+
if GlobalStaff().has_user(request.user):
88+
return True
8689
course = get_course_by_id(CourseKey.from_string(view.kwargs.get('course_id')))
8790
permission = getattr(view, 'permission_name', None)
8891
return request.user.has_perm(permission, course)

lms/djangoapps/instructor/tests/test_api.py

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@
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
33+
from rest_framework.status import (
34+
HTTP_401_UNAUTHORIZED,
35+
HTTP_403_FORBIDDEN,
36+
HTTP_200_OK,
37+
HTTP_404_NOT_FOUND,
38+
HTTP_400_BAD_REQUEST,
39+
HTTP_204_NO_CONTENT,
40+
)
3441
from testfixtures import LogCapture
3542
from rest_framework.test import APITestCase
3643

@@ -165,6 +172,7 @@
165172
'instructor_api_v1:list_instructor_tasks',
166173
'instructor_api_v1:list_report_downloads',
167174
'instructor_api_v1:course_modes_list',
175+
'instructor_api_v1:course_mode_price',
168176
}
169177
INSTRUCTOR_POST_ENDPOINTS = {
170178
'add_users_to_cohorts',
@@ -5215,3 +5223,193 @@ def test_returns_expired_mode(self):
52155223
data['modes'][0]['expiration_datetime'],
52165224
exp_dt.isoformat().replace('+00:00', 'Z')
52175225
)
5226+
5227+
5228+
@ddt.ddt
5229+
class TestCourseModePriceView(SharedModuleStoreTestCase, APITestCase):
5230+
"""
5231+
Test suite for the CourseModePriceView PATCH endpoint.
5232+
5233+
This suite tests the view with the permission class
5234+
(IsAuthenticated, permissions.InstructorPermission).
5235+
"""
5236+
5237+
def setUp(self):
5238+
"""Set up the test environment."""
5239+
super().setUp()
5240+
5241+
self.course = CourseFactory.create()
5242+
self.course_overview = CourseOverviewFactory.create(
5243+
id=self.course.id,
5244+
org='org'
5245+
)
5246+
5247+
self.staff_user = UserFactory(is_staff=True)
5248+
self.student_user = UserFactory()
5249+
self.instructor_user = UserFactory()
5250+
self.staff_user = UserFactory()
5251+
5252+
CourseInstructorRole(self.course.id).add_users(self.instructor_user)
5253+
CourseStaffRole(self.course.id).add_users(self.staff_user)
5254+
5255+
self.verified_mode = CourseModeFactory(
5256+
course_id=self.course_overview.id,
5257+
mode_slug='verified',
5258+
min_price=4900, # $49.00
5259+
currency='USD'
5260+
)
5261+
5262+
self.url = reverse('instructor_api_v1:course_mode_price', kwargs={
5263+
'course_id': self.course.id,
5264+
'mode_slug': self.verified_mode.mode_slug
5265+
})
5266+
5267+
self.valid_payload = {'price': 3900} # $39.00
5268+
5269+
@ddt.data('instructor_user', 'staff_user')
5270+
def test_update_price_success_as_instructor(self, user_type):
5271+
"""
5272+
[204] Test successful price update by an authenticated instructor.
5273+
"""
5274+
# Authenticate as the instructor
5275+
self.client.force_authenticate(user=getattr(self, user_type))
5276+
5277+
response = self.client.patch(
5278+
self.url,
5279+
data=self.valid_payload,
5280+
format='json'
5281+
)
5282+
5283+
# 1. Check for 204 No Content response
5284+
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
5285+
# 2. Verify the price was *actually* changed in the database
5286+
self.verified_mode.refresh_from_db()
5287+
self.assertEqual(self.verified_mode.min_price, self.valid_payload['price'])
5288+
5289+
def test_update_price_forbidden_as_student(self):
5290+
"""
5291+
[403] Test that a non-instructor (student) is forbidden.
5292+
"""
5293+
# Authenticate as the student
5294+
self.client.force_authenticate(user=self.student_user)
5295+
5296+
response = self.client.patch(
5297+
self.url,
5298+
data=self.valid_payload,
5299+
format='json'
5300+
)
5301+
5302+
# 1. Check for 403 Forbidden response
5303+
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
5304+
5305+
# 2. Verify the price was *not* changed
5306+
self.verified_mode.refresh_from_db()
5307+
self.assertEqual(self.verified_mode.min_price, 4900) # Original price
5308+
5309+
def test_update_price_unauthenticated(self):
5310+
"""
5311+
[401] Test that an unauthenticated user is unauthorized.
5312+
"""
5313+
5314+
response = self.client.patch(
5315+
self.url,
5316+
data=self.valid_payload,
5317+
format='json'
5318+
)
5319+
5320+
# 1. Check for 401 Unauthorized response
5321+
self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED)
5322+
5323+
# 2. Verify the price was *not* changed
5324+
self.verified_mode.refresh_from_db()
5325+
self.assertEqual(self.verified_mode.min_price, 4900)
5326+
5327+
def test_update_price_course_not_found(self):
5328+
"""
5329+
[404] Test request for a non-existent course_key.
5330+
"""
5331+
self.client.force_authenticate(user=self.instructor_user)
5332+
5333+
invalid_url = reverse('instructor_api_v1:course_mode_price', kwargs={
5334+
'course_id': 'course-v1:FakeOrg+Nope+123',
5335+
'mode_slug': 'non-existent-mode'
5336+
})
5337+
5338+
response = self.client.patch(
5339+
invalid_url,
5340+
data=self.valid_payload,
5341+
format='json'
5342+
)
5343+
5344+
self.assertEqual(response.status_code, HTTP_404_NOT_FOUND)
5345+
5346+
def test_update_price_mode_not_found(self):
5347+
"""
5348+
[404] Test request for a non-existent mode_slug for the given course.
5349+
"""
5350+
self.client.force_authenticate(user=self.instructor_user)
5351+
5352+
invalid_url = reverse('instructor_api_v1:course_mode_price', kwargs={
5353+
'course_id': self.course.id,
5354+
'mode_slug': 'non-existent-mode'
5355+
})
5356+
response = self.client.patch(
5357+
invalid_url,
5358+
data=self.valid_payload,
5359+
format='json'
5360+
)
5361+
5362+
self.assertEqual(response.status_code, HTTP_404_NOT_FOUND)
5363+
5364+
def test_bad_request_missing_price_field(self):
5365+
"""
5366+
[400] Test request with a missing 'price' field in the body.
5367+
"""
5368+
self.client.force_authenticate(user=self.instructor_user)
5369+
5370+
invalid_payload = {'not_price': 123}
5371+
5372+
response = self.client.patch(
5373+
self.url,
5374+
data=invalid_payload,
5375+
format='json'
5376+
)
5377+
5378+
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
5379+
# Check that the error response correctly identifies the missing field
5380+
self.assertIn('price', response.data)
5381+
self.assertEqual(str(response.data['price'][0]), 'This field is required.')
5382+
5383+
def test_bad_request_invalid_price_negative(self):
5384+
"""
5385+
[400] Test request with an invalid negative 'price'.
5386+
"""
5387+
self.client.force_authenticate(user=self.instructor_user)
5388+
5389+
invalid_payload = {'price': -100}
5390+
5391+
response = self.client.patch(
5392+
self.url,
5393+
data=invalid_payload,
5394+
format='json'
5395+
)
5396+
5397+
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
5398+
self.assertIn('price', response.data)
5399+
5400+
def test_bad_request_invalid_price_string(self):
5401+
"""
5402+
[400] Test request with an invalid string 'price'.
5403+
"""
5404+
self.client.force_authenticate(user=self.instructor_user)
5405+
5406+
invalid_payload = {'price': 'one-hundred'}
5407+
5408+
response = self.client.patch(
5409+
self.url,
5410+
data=invalid_payload,
5411+
format='json'
5412+
)
5413+
5414+
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
5415+
self.assertIn('price', response.data)

lms/djangoapps/instructor/views/api.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
RescoreEntranceExamSerializer,
121121
OverrideProblemScoreSerializer,
122122
StudentsUpdateEnrollmentSerializer,
123-
ResetEntranceExamAttemptsSerializer, CourseModeListSerializer, CourseModeSerializer
123+
ResetEntranceExamAttemptsSerializer, CourseModeListSerializer, CourseModeSerializer, ModePriceUpdateSerializer
124124
)
125125
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
126126
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
@@ -1642,7 +1642,7 @@ def get(self, request, *args, **kwargs):
16421642

16431643
def _cohorts_csv_validator(file_storage, file_to_validate):
16441644
"""
1645-
Verifies that the expected columns are present in the CSV used to add users to cohorts.
1645+
Verifies that the expected columns are present in the CSV` used to add users to cohorts.
16461646
"""
16471647
with file_storage.open(file_to_validate) as f:
16481648
reader = csv.reader(f.read().decode('utf-8-sig').splitlines())
@@ -4432,3 +4432,101 @@ def get(self, request, course_id):
44324432
response_data = {'modes': modes_data}
44334433
response_serializer = CourseModeListSerializer(instance=response_data)
44344434
return Response(response_serializer.data, status=status.HTTP_200_OK)
4435+
4436+
4437+
class CourseModePriceView(GenericAPIView):
4438+
"""
4439+
Updates the price for a specific course enrollment mode.
4440+
4441+
**Content-Type**: Must be `application/merge-patch+json`
4442+
4443+
:param course_id: (Path Param) The unique identifier (course key) for the course.
4444+
:type course_id: string
4445+
:param mode_slug: (Path Param) The enrollment mode identifier (e.g., 'audit', 'verified').
4446+
:type mode_slug: string
4447+
4448+
**Example Request:**
4449+
4450+
.. code-block:: http
4451+
4452+
PATCH /api/instructor/course/modes/course-v1:MyOrg+CS101+2025/verified/price
4453+
Content-Type: application/merge-patch+json
4454+
4455+
**Request Body:**
4456+
4457+
.. code-block:: json
4458+
4459+
{
4460+
"price": 3900
4461+
}
4462+
4463+
**Success Response (204 No Content):**
4464+
4465+
Returns an empty body with a 204 No Content status on success.
4466+
4467+
**Important Notes**:
4468+
- Price is specified in the smallest currency unit (e.g., cents for USD)
4469+
- For example, $49.00 USD should be specified as 4900
4470+
4471+
:raises 400 Bad Request: Invalid request body (e.g., missing 'price' or invalid value).
4472+
:raises 401 Unauthorized: User is not authenticated.
4473+
:raises 403 Forbidden: User lacks Finance Admin or Sales Admin permissions.
4474+
:raises 404 Not Found: The specified `course_key` or `mode_slug` does not exist.
4475+
:raises 415 Unsupported Media Type: Invalid `Content-Type` header.
4476+
"""
4477+
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
4478+
serializer_class = ModePriceUpdateSerializer
4479+
permission_name = VIEW_DASHBOARD
4480+
4481+
@apidocs.schema(
4482+
parameters=[
4483+
apidocs.string_parameter(
4484+
'course_id',
4485+
apidocs.ParameterLocation.PATH,
4486+
description="Course key for the course.",
4487+
),
4488+
apidocs.string_parameter(
4489+
'mode_slug',
4490+
apidocs.ParameterLocation.PATH,
4491+
description="Enrollment mode identifier (e.g., 'verified').",
4492+
),
4493+
],
4494+
responses={
4495+
204: "Mode price updated successfully (no content returned).",
4496+
400: "Invalid request body or parameters.",
4497+
401: "The requesting user is not authenticated.",
4498+
403: "The requesting user lacks Finance/Sales Admin permissions.",
4499+
404: "The requested course or mode does not exist.",
4500+
415: "Unsupported Media Type - must use application/merge-patch+json.",
4501+
},
4502+
)
4503+
def patch(self, request, course_id, mode_slug):
4504+
"""
4505+
Handles the PATCH request to update a course mode's price.
4506+
4507+
Args:
4508+
request (Request): The DRF request object.
4509+
course_id (str): The course key, parsed from the URL.
4510+
mode_slug (str): The mode slug, parsed from the URL.
4511+
4512+
Returns:
4513+
Response: A DRF Response object (204 No Content) or an error.
4514+
"""
4515+
serializer = self.get_serializer(data=request.data)
4516+
if not serializer.is_valid():
4517+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
4518+
4519+
new_price = serializer.validated_data['price']
4520+
4521+
try:
4522+
mode = CourseMode.objects.get(course_id=course_id, mode_slug=mode_slug)
4523+
except CourseMode.DoesNotExist:
4524+
return Response(
4525+
{"error": f"Mode '{mode_slug}' not found for course '{course_id}'."},
4526+
status=status.HTTP_404_NOT_FOUND
4527+
)
4528+
4529+
mode.min_price = new_price
4530+
mode.save()
4531+
4532+
return Response(status=status.HTTP_204_NO_CONTENT)

lms/djangoapps/instructor/views/api_urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
f'courses/{COURSE_ID_PATTERN}/modes',
2222
api.CourseModeListView.as_view(),
2323
name='course_modes_list'
24-
)
24+
),
25+
path(
26+
'course/<course_id>/modes/<mode_slug>/price',
27+
api.CourseModePriceView.as_view(),
28+
name='course_mode_price'
29+
),
2530
]
2631

2732
urlpatterns = [

lms/djangoapps/instructor/views/serializer.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,20 @@ class CourseModeListSerializer(serializers.Serializer):
605605
matching the OpenAPI spec structure.
606606
"""
607607
modes = CourseModeSerializer(many=True, read_only=True)
608+
609+
610+
class ModePriceUpdateSerializer(serializers.Serializer):
611+
"""
612+
Validates the request body for a course mode price update.
613+
614+
Ensures that the request body contains a valid 'price' field.
615+
"""
616+
617+
price = serializers.IntegerField(
618+
required=True,
619+
min_value=0,
620+
help_text="The new price in the smallest currency unit (e.g., cents)."
621+
)
622+
623+
class Meta:
624+
fields = ['price']

0 commit comments

Comments
 (0)