Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions lms/djangoapps/instructor/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission

from common.djangoapps.student.roles import GlobalStaff
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.rules import HasAccessRule, HasRolesRule
from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access
Expand Down Expand Up @@ -83,6 +84,8 @@
class InstructorPermission(BasePermission):
"""Generic permissions"""
def has_permission(self, request, view):
if GlobalStaff().has_user(request.user):
return True
course = get_course_by_id(CourseKey.from_string(view.kwargs.get('course_id')))
permission = getattr(view, 'permission_name', None)
return request.user.has_perm(permission, course)
Expand Down
199 changes: 198 additions & 1 deletion lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@
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 rest_framework.status import (
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_200_OK,
HTTP_404_NOT_FOUND,
HTTP_400_BAD_REQUEST,
HTTP_204_NO_CONTENT,
)
from testfixtures import LogCapture
from rest_framework.test import APITestCase

Expand Down Expand Up @@ -5215,3 +5222,193 @@ def test_returns_expired_mode(self):
data['modes'][0]['expiration_datetime'],
exp_dt.isoformat().replace('+00:00', 'Z')
)


@ddt.ddt
class TestCourseModePriceView(SharedModuleStoreTestCase, APITestCase):
"""
Test suite for the CourseModePriceView PATCH endpoint.

This suite tests the view with the permission class
(IsAuthenticated, permissions.InstructorPermission).
"""

def setUp(self):
"""Set up the test environment."""
super().setUp()

self.course = CourseFactory.create()
self.course_overview = CourseOverviewFactory.create(
id=self.course.id,
org='org'
)

self.staff_user = UserFactory(is_staff=True)
self.student_user = UserFactory()
self.instructor_user = UserFactory()
self.staff_user = UserFactory()

CourseInstructorRole(self.course.id).add_users(self.instructor_user)
CourseStaffRole(self.course.id).add_users(self.staff_user)

self.verified_mode = CourseModeFactory(
course_id=self.course_overview.id,
mode_slug='verified',
min_price=4900, # $49.00
currency='USD'
)

self.url = django_reverse('instructor_api_v1:course_mode_price', kwargs={
'course_id': self.course.id,
'mode_slug': self.verified_mode.mode_slug
})

self.valid_payload = {'price': 3900} # $39.00

@ddt.data('instructor_user', 'staff_user')
def test_update_price_success_as_instructor(self, user_type):
"""
[204] Test successful price update by an authenticated instructor.
"""
# Authenticate as the instructor
self.client.force_authenticate(user=getattr(self, user_type))

response = self.client.patch(
self.url,
data=self.valid_payload,
format='json'
)

# 1. Check for 204 No Content response
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
# 2. Verify the price was *actually* changed in the database
self.verified_mode.refresh_from_db()
self.assertEqual(self.verified_mode.min_price, self.valid_payload['price'])

def test_update_price_forbidden_as_student(self):
"""
[403] Test that a non-instructor (student) is forbidden.
"""
# Authenticate as the student
self.client.force_authenticate(user=self.student_user)

response = self.client.patch(
self.url,
data=self.valid_payload,
format='json'
)

# 1. Check for 403 Forbidden response
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

# 2. Verify the price was *not* changed
self.verified_mode.refresh_from_db()
self.assertEqual(self.verified_mode.min_price, 4900) # Original price

def test_update_price_unauthenticated(self):
"""
[401] Test that an unauthenticated user is unauthorized.
"""

response = self.client.patch(
self.url,
data=self.valid_payload,
format='json'
)

# 1. Check for 401 Unauthorized response
self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED)

# 2. Verify the price was *not* changed
self.verified_mode.refresh_from_db()
self.assertEqual(self.verified_mode.min_price, 4900)

def test_update_price_course_not_found(self):
"""
[404] Test request for a non-existent course_key.
"""
self.client.force_authenticate(user=self.instructor_user)

invalid_url = django_reverse('instructor_api_v1:course_mode_price', kwargs={
'course_id': 'course-v1:FakeOrg+Nope+123',
'mode_slug': 'non-existent-mode'
})

response = self.client.patch(
invalid_url,
data=self.valid_payload,
format='json'
)

self.assertEqual(response.status_code, HTTP_404_NOT_FOUND)

def test_update_price_mode_not_found(self):
"""
[404] Test request for a non-existent mode_slug for the given course.
"""
self.client.force_authenticate(user=self.instructor_user)

invalid_url = django_reverse('instructor_api_v1:course_mode_price', kwargs={
'course_id': self.course.id,
'mode_slug': 'non-existent-mode'
})
response = self.client.patch(
invalid_url,
data=self.valid_payload,
format='json'
)

self.assertEqual(response.status_code, HTTP_404_NOT_FOUND)

def test_bad_request_missing_price_field(self):
"""
[400] Test request with a missing 'price' field in the body.
"""
self.client.force_authenticate(user=self.instructor_user)

invalid_payload = {'not_price': 123}

response = self.client.patch(
self.url,
data=invalid_payload,
format='json'
)

self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
# Check that the error response correctly identifies the missing field
self.assertIn('price', response.data)
self.assertEqual(str(response.data['price'][0]), 'This field is required.')

def test_bad_request_invalid_price_negative(self):
"""
[400] Test request with an invalid negative 'price'.
"""
self.client.force_authenticate(user=self.instructor_user)

invalid_payload = {'price': -100}

response = self.client.patch(
self.url,
data=invalid_payload,
format='json'
)

self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
self.assertIn('price', response.data)

def test_bad_request_invalid_price_string(self):
"""
[400] Test request with an invalid string 'price'.
"""
self.client.force_authenticate(user=self.instructor_user)

invalid_payload = {'price': 'one-hundred'}

response = self.client.patch(
self.url,
data=invalid_payload,
format='json'
)

self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
self.assertIn('price', response.data)
102 changes: 100 additions & 2 deletions lms/djangoapps/instructor/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
RescoreEntranceExamSerializer,
OverrideProblemScoreSerializer,
StudentsUpdateEnrollmentSerializer,
ResetEntranceExamAttemptsSerializer, CourseModeListSerializer, CourseModeSerializer
ResetEntranceExamAttemptsSerializer, CourseModeListSerializer, CourseModeSerializer, ModePriceUpdateSerializer
)
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 @@ -1642,7 +1642,7 @@ def get(self, request, *args, **kwargs):

def _cohorts_csv_validator(file_storage, file_to_validate):
"""
Verifies that the expected columns are present in the CSV used to add users to cohorts.
Verifies that the expected columns are present in the CSV` used to add users to cohorts.
"""
with file_storage.open(file_to_validate) as f:
reader = csv.reader(f.read().decode('utf-8-sig').splitlines())
Expand Down Expand Up @@ -4432,3 +4432,101 @@ def get(self, request, course_id):
response_data = {'modes': modes_data}
response_serializer = CourseModeListSerializer(instance=response_data)
return Response(response_serializer.data, status=status.HTTP_200_OK)


class CourseModePriceView(GenericAPIView):
"""
Updates the price for a specific course enrollment mode.

**Content-Type**: Must be `application/merge-patch+json`

:param course_id: (Path Param) The unique identifier (course key) for the course.
:type course_id: string
:param mode_slug: (Path Param) The enrollment mode identifier (e.g., 'audit', 'verified').
:type mode_slug: string

**Example Request:**

.. code-block:: http

PATCH /api/instructor/course/modes/course-v1:MyOrg+CS101+2025/verified/price
Content-Type: application/merge-patch+json

**Request Body:**

.. code-block:: json

{
"price": 3900
}

**Success Response (204 No Content):**

Returns an empty body with a 204 No Content status on success.

**Important Notes**:
- Price is specified in the smallest currency unit (e.g., cents for USD)
- For example, $49.00 USD should be specified as 4900

:raises 400 Bad Request: Invalid request body (e.g., missing 'price' or invalid value).
:raises 401 Unauthorized: User is not authenticated.
:raises 403 Forbidden: User lacks Finance Admin or Sales Admin permissions.
:raises 404 Not Found: The specified `course_key` or `mode_slug` does not exist.
:raises 415 Unsupported Media Type: Invalid `Content-Type` header.
"""
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
serializer_class = ModePriceUpdateSerializer
permission_name = VIEW_DASHBOARD

@apidocs.schema(
parameters=[
apidocs.string_parameter(
'course_id',
apidocs.ParameterLocation.PATH,
description="Course key for the course.",
),
apidocs.string_parameter(
'mode_slug',
apidocs.ParameterLocation.PATH,
description="Enrollment mode identifier (e.g., 'verified').",
),
],
responses={
204: "Mode price updated successfully (no content returned).",
400: "Invalid request body or parameters.",
401: "The requesting user is not authenticated.",
403: "The requesting user lacks Finance/Sales Admin permissions.",
404: "The requested course or mode does not exist.",
415: "Unsupported Media Type - must use application/merge-patch+json.",
},
)
def patch(self, request, course_id, mode_slug):
"""
Handles the PATCH request to update a course mode's price.

Args:
request (Request): The DRF request object.
course_id (str): The course key, parsed from the URL.
mode_slug (str): The mode slug, parsed from the URL.

Returns:
Response: A DRF Response object (204 No Content) or an error.
"""
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

new_price = serializer.validated_data['price']

try:
mode = CourseMode.objects.get(course_id=course_id, mode_slug=mode_slug)
except CourseMode.DoesNotExist:
return Response(
{"error": f"Mode '{mode_slug}' not found for course '{course_id}'."},
status=status.HTTP_404_NOT_FOUND
)

mode.min_price = new_price
mode.save()

return Response(status=status.HTTP_204_NO_CONTENT)
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 @@ -21,7 +21,12 @@
f'courses/{COURSE_ID_PATTERN}/modes',
api.CourseModeListView.as_view(),
name='course_modes_list'
)
),
path(
'course/<course_id>/modes/<mode_slug>/price',
api.CourseModePriceView.as_view(),
name='course_mode_price'
),
]

urlpatterns = [
Expand Down
17 changes: 17 additions & 0 deletions lms/djangoapps/instructor/views/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,3 +605,20 @@ class CourseModeListSerializer(serializers.Serializer):
matching the OpenAPI spec structure.
"""
modes = CourseModeSerializer(many=True, read_only=True)


class ModePriceUpdateSerializer(serializers.Serializer):
"""
Validates the request body for a course mode price update.

Ensures that the request body contains a valid 'price' field.
"""

price = serializers.IntegerField(
required=True,
min_value=0,
help_text="The new price in the smallest currency unit (e.g., cents)."
)

class Meta:
fields = ['price']
Loading