Skip to content

Commit 7cfdc7a

Browse files
feat: implement instructor API v2 grading GET endpoints
1 parent 2248575 commit 7cfdc7a

4 files changed

Lines changed: 717 additions & 0 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"""
2+
Tests for Instructor API v2 GET endpoints.
3+
"""
4+
import json
5+
from datetime import datetime, timezone
6+
from unittest.mock import patch
7+
from uuid import uuid4
8+
from django.urls import reverse
9+
from rest_framework import status
10+
from rest_framework.test import APIClient
11+
from common.djangoapps.student.tests.factories import UserFactory
12+
from lms.djangoapps.instructor_task.models import InstructorTask
13+
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
14+
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
15+
16+
17+
class LearnerViewTestCase(ModuleStoreTestCase):
18+
"""
19+
Tests for GET /api/instructor/v2/courses/{course_key}/learners/{email_or_username}
20+
"""
21+
22+
def setUp(self):
23+
super().setUp()
24+
self.client = APIClient()
25+
self.instructor = UserFactory(is_staff=False)
26+
self.student = UserFactory(
27+
username='john_harvard',
28+
email='john@example.com',
29+
first_name='John',
30+
last_name='Harvard'
31+
)
32+
self.course = CourseFactory.create()
33+
self.client.force_authenticate(user=self.instructor)
34+
35+
@patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission')
36+
def test_get_learner_by_username(self, mock_perm):
37+
"""Test retrieving learner info by username"""
38+
mock_perm.return_value = True
39+
40+
url = reverse('instructor_api_v2:learner_detail', kwargs={
41+
'course_id': str(self.course.id),
42+
'email_or_username': self.student.username
43+
})
44+
response = self.client.get(url)
45+
46+
self.assertEqual(response.status_code, status.HTTP_200_OK)
47+
data = response.json()
48+
self.assertEqual(data['username'], 'john_harvard')
49+
self.assertEqual(data['email'], 'john@example.com')
50+
self.assertEqual(data['first_name'], 'John')
51+
self.assertEqual(data['last_name'], 'Harvard')
52+
53+
@patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission')
54+
def test_get_learner_by_email(self, mock_perm):
55+
"""Test retrieving learner info by email"""
56+
mock_perm.return_value = True
57+
58+
url = reverse('instructor_api_v2:learner_detail', kwargs={
59+
'course_id': str(self.course.id),
60+
'email_or_username': self.student.email
61+
})
62+
response = self.client.get(url)
63+
64+
self.assertEqual(response.status_code, status.HTTP_200_OK)
65+
data = response.json()
66+
self.assertEqual(data['username'], 'john_harvard')
67+
self.assertEqual(data['email'], 'john@example.com')
68+
69+
def test_get_learner_requires_authentication(self):
70+
"""Test that endpoint requires authentication"""
71+
self.client.force_authenticate(user=None)
72+
73+
url = reverse('instructor_api_v2:learner_detail', kwargs={
74+
'course_id': str(self.course.id),
75+
'email_or_username': self.student.username
76+
})
77+
response = self.client.get(url)
78+
79+
self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
80+
81+
82+
class ProblemViewTestCase(ModuleStoreTestCase):
83+
"""
84+
Tests for GET /api/instructor/v2/courses/{course_key}/problems/{location}
85+
"""
86+
87+
def setUp(self):
88+
super().setUp()
89+
self.client = APIClient()
90+
self.instructor = UserFactory(is_staff=False)
91+
self.course = CourseFactory.create(display_name='Test Course')
92+
self.chapter = BlockFactory.create(
93+
parent=self.course,
94+
category='chapter',
95+
display_name='Week 1'
96+
)
97+
self.sequential = BlockFactory.create(
98+
parent=self.chapter,
99+
category='sequential',
100+
display_name='Homework 1'
101+
)
102+
self.problem = BlockFactory.create(
103+
parent=self.sequential,
104+
category='problem',
105+
display_name='Sample Problem'
106+
)
107+
self.client.force_authenticate(user=self.instructor)
108+
109+
@patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission')
110+
def test_get_problem_metadata(self, mock_perm):
111+
"""Test retrieving problem metadata"""
112+
mock_perm.return_value = True
113+
114+
url = reverse('instructor_api_v2:problem_detail', kwargs={
115+
'course_id': str(self.course.id),
116+
'location': str(self.problem.location)
117+
})
118+
response = self.client.get(url)
119+
120+
self.assertEqual(response.status_code, status.HTTP_200_OK)
121+
data = response.json()
122+
self.assertEqual(data['id'], str(self.problem.location))
123+
self.assertEqual(data['name'], 'Sample Problem')
124+
self.assertIn('breadcrumbs', data)
125+
self.assertIsInstance(data['breadcrumbs'], list)
126+
127+
@patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission')
128+
def test_get_problem_with_breadcrumbs(self, mock_perm):
129+
"""Test that breadcrumbs are included in response"""
130+
mock_perm.return_value = True
131+
132+
url = reverse('instructor_api_v2:problem_detail', kwargs={
133+
'course_id': str(self.course.id),
134+
'location': str(self.problem.location)
135+
})
136+
response = self.client.get(url)
137+
138+
self.assertEqual(response.status_code, status.HTTP_200_OK)
139+
data = response.json()
140+
breadcrumbs = data['breadcrumbs']
141+
142+
# Should have at least the problem itself
143+
self.assertGreater(len(breadcrumbs), 0)
144+
# Check that breadcrumb items have required fields
145+
for crumb in breadcrumbs:
146+
self.assertIn('display_name', crumb)
147+
148+
@patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission')
149+
def test_get_problem_invalid_location(self, mock_perm):
150+
"""Test 400 with invalid problem location"""
151+
mock_perm.return_value = True
152+
153+
url = reverse('instructor_api_v2:problem_detail', kwargs={
154+
'course_id': str(self.course.id),
155+
'location': 'invalid-location'
156+
})
157+
response = self.client.get(url)
158+
159+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
160+
self.assertIn('error', response.json())
161+
162+
def test_get_problem_requires_authentication(self):
163+
"""Test that endpoint requires authentication"""
164+
self.client.force_authenticate(user=None)
165+
166+
url = reverse('instructor_api_v2:problem_detail', kwargs={
167+
'course_id': str(self.course.id),
168+
'location': str(self.problem.location)
169+
})
170+
response = self.client.get(url)
171+
172+
self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
173+
174+
175+
class TaskStatusViewTestCase(ModuleStoreTestCase):
176+
"""
177+
Tests for GET /api/instructor/v2/courses/{course_key}/tasks/{task_id}
178+
"""
179+
180+
def setUp(self):
181+
super().setUp()
182+
self.client = APIClient()
183+
self.instructor = UserFactory(is_staff=False)
184+
self.course = CourseFactory.create()
185+
self.client.force_authenticate(user=self.instructor)
186+
187+
@patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission')
188+
def test_get_task_status_completed(self, mock_perm):
189+
"""Test retrieving completed task status"""
190+
mock_perm.return_value = True
191+
192+
# Create a completed task
193+
task_id = str(uuid4())
194+
task_output = json.dumps({
195+
'current': 150,
196+
'total': 150,
197+
'message': 'Reset attempts for 150 learners'
198+
})
199+
task = InstructorTask.objects.create(
200+
course_id=self.course.id,
201+
task_type='rescore_problem',
202+
task_key='',
203+
task_input='{}',
204+
task_id=task_id,
205+
task_state='SUCCESS',
206+
task_output=task_output,
207+
requester=self.instructor
208+
)
209+
210+
url = reverse('instructor_api_v2:task_status', kwargs={
211+
'course_id': str(self.course.id),
212+
'task_id': task_id
213+
})
214+
response = self.client.get(url)
215+
216+
self.assertEqual(response.status_code, status.HTTP_200_OK)
217+
data = response.json()
218+
self.assertEqual(data['task_id'], task_id)
219+
self.assertEqual(data['state'], 'completed')
220+
self.assertIn('progress', data)
221+
self.assertEqual(data['progress']['current'], 150)
222+
self.assertEqual(data['progress']['total'], 150)
223+
self.assertIn('result', data)
224+
self.assertTrue(data['result']['success'])
225+
226+
@patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission')
227+
def test_get_task_status_running(self, mock_perm):
228+
"""Test retrieving running task status"""
229+
mock_perm.return_value = True
230+
231+
# Create a running task
232+
task_id = str(uuid4())
233+
task_output = json.dumps({'current': 75, 'total': 150})
234+
InstructorTask.objects.create(
235+
course_id=self.course.id,
236+
task_type='rescore_problem',
237+
task_key='',
238+
task_input='{}',
239+
task_id=task_id,
240+
task_state='PROGRESS',
241+
task_output=task_output,
242+
requester=self.instructor
243+
)
244+
245+
url = reverse('instructor_api_v2:task_status', kwargs={
246+
'course_id': str(self.course.id),
247+
'task_id': task_id
248+
})
249+
response = self.client.get(url)
250+
251+
self.assertEqual(response.status_code, status.HTTP_200_OK)
252+
data = response.json()
253+
self.assertEqual(data['state'], 'running')
254+
self.assertIn('progress', data)
255+
self.assertEqual(data['progress']['current'], 75)
256+
self.assertEqual(data['progress']['total'], 150)
257+
258+
@patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission')
259+
def test_get_task_status_failed(self, mock_perm):
260+
"""Test retrieving failed task status"""
261+
mock_perm.return_value = True
262+
263+
# Create a failed task
264+
task_id = str(uuid4())
265+
InstructorTask.objects.create(
266+
course_id=self.course.id,
267+
task_type='rescore_problem',
268+
task_key='',
269+
task_input='{}',
270+
task_id=task_id,
271+
task_state='FAILURE',
272+
task_output='Task execution failed',
273+
requester=self.instructor
274+
)
275+
276+
url = reverse('instructor_api_v2:task_status', kwargs={
277+
'course_id': str(self.course.id),
278+
'task_id': task_id
279+
})
280+
response = self.client.get(url)
281+
282+
self.assertEqual(response.status_code, status.HTTP_200_OK)
283+
data = response.json()
284+
self.assertEqual(data['state'], 'failed')
285+
self.assertIn('error', data)
286+
self.assertIn('code', data['error'])
287+
self.assertIn('message', data['error'])
288+
289+
def test_get_task_requires_authentication(self):
290+
"""Test that endpoint requires authentication"""
291+
self.client.force_authenticate(user=None)
292+
293+
url = reverse('instructor_api_v2:task_status', kwargs={
294+
'course_id': str(self.course.id),
295+
'task_id': 'some-task-id'
296+
})
297+
response = self.client.get(url)
298+
299+
self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])

lms/djangoapps/instructor/views/api_urls.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@
5656
api_v2.ORASummaryView.as_view(),
5757
name='ora_summary'
5858
),
59+
re_path(
60+
rf'^courses/{COURSE_ID_PATTERN}/learners/(?P<email_or_username>[^/]+)$',
61+
api_v2.LearnerView.as_view(),
62+
name='learner_detail'
63+
),
64+
re_path(
65+
rf'^courses/{COURSE_ID_PATTERN}/problems/(?P<location>.+)$',
66+
api_v2.ProblemView.as_view(),
67+
name='problem_detail'
68+
),
69+
re_path(
70+
rf'^courses/{COURSE_ID_PATTERN}/tasks/(?P<task_id>[^/]+)$',
71+
api_v2.TaskStatusView.as_view(),
72+
name='task_status'
73+
)
5974
]
6075

6176
urlpatterns = [

0 commit comments

Comments
 (0)