Skip to content

Commit 55e65a1

Browse files
feat: Add enrollments actions endpoints
1 parent 76fdce3 commit 55e65a1

4 files changed

Lines changed: 551 additions & 3 deletions

File tree

lms/djangoapps/instructor/tests/test_api_v2.py

Lines changed: 248 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
from rest_framework.test import APIClient, APITestCase
1818

1919
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
20-
from common.djangoapps.student.models.course_enrollment import CourseEnrollment
20+
from common.djangoapps.course_modes.models import CourseMode
21+
from common.djangoapps.student.models import ManualEnrollmentAudit
22+
from common.djangoapps.student.models.course_enrollment import CourseEnrollment, CourseEnrollmentAllowed
2123
from common.djangoapps.student.roles import CourseBetaTesterRole, CourseDataResearcherRole, CourseInstructorRole
2224
from common.djangoapps.student.tests.factories import (
2325
AdminFactory,
@@ -2049,3 +2051,248 @@ def test_filter_beta_testers_with_search(self):
20492051
data = response.data
20502052
self.assertEqual(data['count'], 1)
20512053
self.assertTrue(data['results'][0]['is_beta_tester'])
2054+
2055+
2056+
class EnrollmentStatusViewTest(SharedModuleStoreTestCase):
2057+
"""Tests for the EnrollmentStatusView v2 GET endpoint."""
2058+
2059+
@classmethod
2060+
def setUpClass(cls):
2061+
super().setUpClass()
2062+
cls.course = CourseFactory.create()
2063+
2064+
def setUp(self):
2065+
super().setUp()
2066+
self.client = APIClient()
2067+
self.instructor = InstructorFactory(course_key=self.course.id)
2068+
self.url = reverse(
2069+
'instructor_api_v2:enrollment_status',
2070+
kwargs={'course_id': str(self.course.id)}
2071+
)
2072+
2073+
def test_unauthenticated_returns_401(self):
2074+
response = self.client.get(self.url, {'email_or_username': 'test'})
2075+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
2076+
2077+
def test_student_returns_403(self):
2078+
student = UserFactory()
2079+
self.client.force_authenticate(user=student)
2080+
response = self.client.get(self.url, {'email_or_username': 'test'})
2081+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
2082+
2083+
def test_missing_identifier_returns_400(self):
2084+
self.client.force_authenticate(user=self.instructor)
2085+
response = self.client.get(self.url)
2086+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
2087+
2088+
def test_empty_identifier_returns_400(self):
2089+
self.client.force_authenticate(user=self.instructor)
2090+
response = self.client.get(self.url, {'email_or_username': ''})
2091+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
2092+
2093+
def test_active_enrollment(self):
2094+
learner = UserFactory()
2095+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, is_active=True)
2096+
self.client.force_authenticate(user=self.instructor)
2097+
response = self.client.get(self.url, {'email_or_username': learner.email})
2098+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2099+
data = response.data
2100+
self.assertEqual(data['enrollment_status'], 'active')
2101+
self.assertIsNotNone(data['mode'])
2102+
self.assertEqual(data['course_id'], str(self.course.id))
2103+
self.assertEqual(data['email_or_username'], learner.email)
2104+
self.assertIn('active', data['message'])
2105+
2106+
def test_inactive_enrollment(self):
2107+
learner = UserFactory()
2108+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, is_active=False)
2109+
self.client.force_authenticate(user=self.instructor)
2110+
response = self.client.get(self.url, {'email_or_username': learner.username})
2111+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2112+
self.assertEqual(response.data['enrollment_status'], 'inactive')
2113+
2114+
def test_pending_enrollment(self):
2115+
2116+
CourseEnrollmentAllowed.objects.create(
2117+
email=email,
2118+
course_id=self.course.id,
2119+
)
2120+
self.client.force_authenticate(user=self.instructor)
2121+
response = self.client.get(self.url, {'email_or_username': email})
2122+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2123+
self.assertEqual(response.data['enrollment_status'], 'pending')
2124+
self.assertIsNone(response.data['mode'])
2125+
2126+
def test_never_enrolled_existing_user(self):
2127+
learner = UserFactory()
2128+
self.client.force_authenticate(user=self.instructor)
2129+
response = self.client.get(self.url, {'email_or_username': learner.username})
2130+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2131+
self.assertEqual(response.data['enrollment_status'], 'never_enrolled')
2132+
self.assertIsNone(response.data['mode'])
2133+
2134+
def test_never_enrolled_nonexistent_user(self):
2135+
self.client.force_authenticate(user=self.instructor)
2136+
response = self.client.get(self.url, {'email_or_username': '[email protected]'})
2137+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2138+
self.assertEqual(response.data['enrollment_status'], 'never_enrolled')
2139+
2140+
def test_lookup_by_username(self):
2141+
learner = UserFactory()
2142+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, is_active=True)
2143+
self.client.force_authenticate(user=self.instructor)
2144+
response = self.client.get(self.url, {'email_or_username': learner.username})
2145+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2146+
self.assertEqual(response.data['enrollment_status'], 'active')
2147+
2148+
def test_staff_can_access(self):
2149+
staff = StaffFactory(course_key=self.course.id)
2150+
learner = UserFactory()
2151+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, is_active=True)
2152+
self.client.force_authenticate(user=staff)
2153+
response = self.client.get(self.url, {'email_or_username': learner.email})
2154+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2155+
2156+
2157+
class ChangeEnrollmentModeViewTest(SharedModuleStoreTestCase):
2158+
"""Tests for the ChangeEnrollmentModeView v2 POST endpoint."""
2159+
2160+
@classmethod
2161+
def setUpClass(cls):
2162+
super().setUpClass()
2163+
cls.course = CourseFactory.create()
2164+
2165+
def setUp(self):
2166+
super().setUp()
2167+
self.client = APIClient()
2168+
self.instructor = InstructorFactory(course_key=self.course.id)
2169+
self.url = reverse(
2170+
'instructor_api_v2:change_enrollment_mode',
2171+
kwargs={'course_id': str(self.course.id)}
2172+
)
2173+
# Ensure audit and verified modes exist
2174+
CourseModeFactory(course_id=self.course.id, mode_slug='audit')
2175+
CourseModeFactory(course_id=self.course.id, mode_slug='verified')
2176+
2177+
def test_unauthenticated_returns_401(self):
2178+
response = self.client.post(self.url, {'email_or_username': 'x', 'mode': 'verified'})
2179+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
2180+
2181+
def test_student_returns_403(self):
2182+
student = UserFactory()
2183+
self.client.force_authenticate(user=student)
2184+
response = self.client.post(self.url, {'email_or_username': 'x', 'mode': 'verified'})
2185+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
2186+
2187+
def test_missing_fields_returns_400(self):
2188+
self.client.force_authenticate(user=self.instructor)
2189+
response = self.client.post(self.url, {})
2190+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
2191+
2192+
def test_nonexistent_user_returns_404(self):
2193+
self.client.force_authenticate(user=self.instructor)
2194+
response = self.client.post(self.url, {
2195+
'email_or_username': '[email protected]',
2196+
'mode': 'verified',
2197+
})
2198+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
2199+
2200+
def test_user_not_enrolled_returns_404(self):
2201+
learner = UserFactory()
2202+
self.client.force_authenticate(user=self.instructor)
2203+
response = self.client.post(self.url, {
2204+
'email_or_username': learner.email,
2205+
'mode': 'verified',
2206+
})
2207+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
2208+
2209+
def test_inactive_enrollment_returns_404(self):
2210+
learner = UserFactory()
2211+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, is_active=False)
2212+
self.client.force_authenticate(user=self.instructor)
2213+
response = self.client.post(self.url, {
2214+
'email_or_username': learner.email,
2215+
'mode': 'verified',
2216+
})
2217+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
2218+
2219+
def test_invalid_mode_returns_400(self):
2220+
learner = UserFactory()
2221+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, mode='audit')
2222+
self.client.force_authenticate(user=self.instructor)
2223+
response = self.client.post(self.url, {
2224+
'email_or_username': learner.email,
2225+
'mode': 'nonexistent_mode',
2226+
})
2227+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
2228+
self.assertIn('Invalid mode', response.data['error'])
2229+
2230+
def test_same_mode_returns_400(self):
2231+
learner = UserFactory()
2232+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, mode='audit')
2233+
self.client.force_authenticate(user=self.instructor)
2234+
response = self.client.post(self.url, {
2235+
'email_or_username': learner.email,
2236+
'mode': 'audit',
2237+
})
2238+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
2239+
self.assertIn('already enrolled', response.data['error'])
2240+
2241+
def test_successful_mode_change(self):
2242+
learner = UserFactory()
2243+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, mode='audit')
2244+
self.client.force_authenticate(user=self.instructor)
2245+
response = self.client.post(self.url, {
2246+
'email_or_username': learner.email,
2247+
'mode': 'verified',
2248+
})
2249+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2250+
data = response.data
2251+
self.assertEqual(data['old_mode'], 'audit')
2252+
self.assertEqual(data['new_mode'], 'verified')
2253+
self.assertEqual(data['course_id'], str(self.course.id))
2254+
self.assertEqual(data['email_or_username'], learner.email)
2255+
2256+
# Verify the enrollment was actually changed
2257+
enrollment = CourseEnrollment.get_enrollment(learner, self.course.id)
2258+
self.assertEqual(enrollment.mode, 'verified')
2259+
2260+
def test_audit_trail_created(self):
2261+
learner = UserFactory()
2262+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, mode='audit')
2263+
self.client.force_authenticate(user=self.instructor)
2264+
response = self.client.post(self.url, {
2265+
'email_or_username': learner.email,
2266+
'mode': 'verified',
2267+
'reason': 'Student upgraded',
2268+
})
2269+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2270+
2271+
enrollment = CourseEnrollment.get_enrollment(learner, self.course.id)
2272+
audit = ManualEnrollmentAudit.objects.filter(enrollment=enrollment).first()
2273+
self.assertIsNotNone(audit)
2274+
self.assertEqual(audit.enrolled_email, learner.email)
2275+
self.assertIn('mode changed from audit to verified', audit.state_transition)
2276+
self.assertEqual(audit.reason, 'Student upgraded')
2277+
2278+
def test_lookup_by_username(self):
2279+
learner = UserFactory()
2280+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, mode='audit')
2281+
self.client.force_authenticate(user=self.instructor)
2282+
response = self.client.post(self.url, {
2283+
'email_or_username': learner.username,
2284+
'mode': 'verified',
2285+
})
2286+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2287+
self.assertEqual(response.data['new_mode'], 'verified')
2288+
2289+
def test_staff_can_access(self):
2290+
staff = StaffFactory(course_key=self.course.id)
2291+
learner = UserFactory()
2292+
CourseEnrollmentFactory(user=learner, course_id=self.course.id, mode='audit')
2293+
self.client.force_authenticate(user=staff)
2294+
response = self.client.post(self.url, {
2295+
'email_or_username': learner.email,
2296+
'mode': 'verified',
2297+
})
2298+
self.assertEqual(response.status_code, status.HTTP_200_OK)

lms/djangoapps/instructor/views/api_urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@
7171
api_v2.CourseEnrollmentsView.as_view(),
7272
name='course_enrollments'
7373
),
74+
re_path(
75+
rf'^courses/{COURSE_ID_PATTERN}/enrollment_status$',
76+
api_v2.EnrollmentStatusView.as_view(),
77+
name='enrollment_status'
78+
),
79+
re_path(
80+
rf'^courses/{COURSE_ID_PATTERN}/change_enrollment_mode$',
81+
api_v2.ChangeEnrollmentModeView.as_view(),
82+
name='change_enrollment_mode'
83+
),
7484
]
7585

7686
urlpatterns = [

0 commit comments

Comments
 (0)