Skip to content

Commit 2151160

Browse files
feat: instructor dash - change due date v2 endpoint (openedx#37685)
* feat: added extensions v2 endpoint
1 parent 0d6c833 commit 2151160

File tree

4 files changed

+279
-2
lines changed

4 files changed

+279
-2
lines changed

lms/djangoapps/instructor/tests/test_api.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
'bulk_beta_modify_access',
167167
'calculate_grades_csv',
168168
'change_due_date',
169+
'instructor_api_v2:change_due_date',
169170
'export_ora2_data',
170171
'export_ora2_submission_files',
171172
'export_ora2_summary',
@@ -4494,6 +4495,180 @@ def test_show_student_extensions(self):
44944495
'title': (f'Due date extensions for {self.user1.profile.name} ({self.user1.username})')}
44954496

44964497

4498+
class TestChangeDueDateV2(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
4499+
"""
4500+
Tests for the V2 due date extension API endpoint.
4501+
"""
4502+
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
4503+
4504+
@classmethod
4505+
def setUpClass(cls):
4506+
super().setUpClass()
4507+
cls.course = CourseFactory.create()
4508+
cls.due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=UTC)
4509+
4510+
with cls.store.bulk_operations(cls.course.id, emit_signals=False):
4511+
cls.week1 = BlockFactory.create(due=cls.due)
4512+
cls.week2 = BlockFactory.create(due=cls.due)
4513+
cls.week3 = BlockFactory.create() # No due date
4514+
cls.course.children = [
4515+
str(cls.week1.location),
4516+
str(cls.week2.location),
4517+
str(cls.week3.location)
4518+
]
4519+
cls.homework = BlockFactory.create(
4520+
parent_location=cls.week1.location,
4521+
due=cls.due
4522+
)
4523+
cls.week1.children = [str(cls.homework.location)]
4524+
4525+
def setUp(self):
4526+
"""
4527+
Fixtures.
4528+
"""
4529+
super().setUp()
4530+
4531+
self.user1 = UserFactory.create()
4532+
self.user2 = UserFactory.create()
4533+
4534+
# Create StudentModule objects for tracking student progress
4535+
StudentModule(
4536+
state='{}',
4537+
student_id=self.user1.id,
4538+
course_id=self.course.id,
4539+
module_state_key=self.week1.location).save()
4540+
StudentModule(
4541+
state='{}',
4542+
student_id=self.user1.id,
4543+
course_id=self.course.id,
4544+
module_state_key=self.homework.location).save()
4545+
StudentModule(
4546+
state='{}',
4547+
student_id=self.user2.id,
4548+
course_id=self.course.id,
4549+
module_state_key=self.week1.location).save()
4550+
StudentModule(
4551+
state='{}',
4552+
student_id=self.user2.id,
4553+
course_id=self.course.id,
4554+
module_state_key=self.homework.location).save()
4555+
4556+
CourseEnrollmentFactory.create(user=self.user1, course_id=self.course.id)
4557+
CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id)
4558+
self.instructor = InstructorFactory(course_key=self.course.id)
4559+
self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
4560+
extract_dates(None, self.course.id)
4561+
4562+
def test_change_due_date_v2_success(self):
4563+
"""Test successful due date change using V2 API endpoint"""
4564+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4565+
due_date = datetime.datetime(2013, 12, 30, tzinfo=UTC)
4566+
response = self.client.post(url, json.dumps({
4567+
'email_or_username': self.user1.username,
4568+
'block_id': str(self.homework.location),
4569+
'due_datetime': '12/30/2013 00:00',
4570+
'reason': 'Testing V2 API.'
4571+
}), content_type='application/json')
4572+
4573+
assert response.status_code == 200, response.content
4574+
response_data = json.loads(response.content.decode('utf-8'))
4575+
assert 'Successfully changed due date for learner' in response_data['message']
4576+
4577+
assert get_extended_due(self.course, self.homework, self.user1) == due_date
4578+
4579+
def test_change_due_date_v2_with_email(self):
4580+
"""Test due date change using email instead of username"""
4581+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4582+
due_date = datetime.datetime(2013, 12, 30, tzinfo=UTC)
4583+
response = self.client.post(url, json.dumps({
4584+
'email_or_username': self.user1.email,
4585+
'block_id': str(self.homework.location),
4586+
'due_datetime': '12/30/2013 00:00',
4587+
'reason': 'Testing V2 API with email.'
4588+
}), content_type='application/json')
4589+
4590+
assert response.status_code == 200, response.content
4591+
response_data = json.loads(response.content.decode('utf-8'))
4592+
assert 'Successfully changed due date for learner' in response_data['message']
4593+
4594+
assert get_extended_due(self.course, self.homework, self.user1) == due_date
4595+
4596+
def test_change_due_date_v2_invalid_user(self):
4597+
"""Test error handling for invalid user"""
4598+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4599+
response = self.client.post(url, json.dumps({
4600+
'email_or_username': 'nonexistent@example.com',
4601+
'block_id': str(self.homework.location),
4602+
'due_datetime': '12/30/2013 00:00'
4603+
}), content_type='application/json')
4604+
4605+
assert response.status_code == 400, response.content
4606+
response_data = json.loads(response.content.decode('utf-8'))
4607+
assert 'Invalid learner identifier' in response_data['error']['email_or_username'][0]
4608+
4609+
def test_change_due_date_v2_invalid_block(self):
4610+
"""Test error handling for invalid block location"""
4611+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4612+
# Invalid block location should cause an exception (500 error)
4613+
with self.assertRaises(Exception):
4614+
self.client.post(url, json.dumps({
4615+
'email_or_username': self.user1.username,
4616+
'block_id': 'i4x://invalid/block/location',
4617+
'due_datetime': '12/30/2013 00:00'
4618+
}), content_type='application/json')
4619+
4620+
def test_change_due_date_v2_invalid_date_format(self):
4621+
"""Test error handling for invalid date format"""
4622+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4623+
# V2 API accepts form data as well
4624+
response = self.client.post(url, {
4625+
'email_or_username': self.user1.username,
4626+
'block_id': self.homework.location,
4627+
'due_datetime': 'invalid-date-format'
4628+
})
4629+
4630+
self.assertEqual(response.status_code, 400)
4631+
4632+
def test_change_due_date_v2_missing_fields(self):
4633+
"""Test error handling for missing required fields"""
4634+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4635+
# V2 API accepts form data
4636+
response = self.client.post(url, {
4637+
'email_or_username': self.user1.username,
4638+
# Missing 'block_id' and 'due_datetime'
4639+
})
4640+
4641+
self.assertEqual(response.status_code, 400)
4642+
4643+
def test_change_due_date_v2_unenrolled_user(self):
4644+
"""Test error handling for user not enrolled in course"""
4645+
unenrolled_user = UserFactory.create()
4646+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4647+
# V2 API accepts form data
4648+
response = self.client.post(url, {
4649+
'email_or_username': unenrolled_user.username,
4650+
'block_id': self.homework.location,
4651+
'due_datetime': '12/30/2013 00:00'
4652+
})
4653+
4654+
self.assertEqual(response.status_code, 400)
4655+
4656+
def test_change_due_date_v2_json_content_type(self):
4657+
"""Test that V2 API works with both JSON and form data"""
4658+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4659+
# Send as form data instead of JSON
4660+
response = self.client.post(url, {
4661+
'email_or_username': self.user1.username,
4662+
'block_id': self.homework.location,
4663+
'due_datetime': '12/30/2013 00:00'
4664+
})
4665+
4666+
# The V2 endpoint works with form data and should succeed
4667+
self.assertEqual(response.status_code, 200)
4668+
response_data = json.loads(response.content.decode('utf-8'))
4669+
self.assertIn('Successfully changed due date for learner', response_data['message'])
4670+
4671+
44974672
class TestDueDateExtensionsDeletedDate(ModuleStoreTestCase, LoginEnrollmentTestCase):
44984673
"""
44994674
Tests for deleting due date extensions

lms/djangoapps/instructor/views/api_urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
api_v2.InstructorTaskListView.as_view(),
3232
name='instructor_tasks'
3333
),
34+
re_path(
35+
rf'^courses/{COURSE_ID_PATTERN}/change_due_date$',
36+
api_v2.ChangeDueDateView.as_view(),
37+
name='change_due_date'
38+
),
3439
]
3540

3641
urlpatterns = [

lms/djangoapps/instructor/views/api_v2.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,27 @@
1414
from rest_framework.permissions import IsAuthenticated
1515
from rest_framework.response import Response
1616
from rest_framework.views import APIView
17+
from django.utils.decorators import method_decorator
18+
from django.views.decorators.cache import cache_control
19+
from django.utils.html import strip_tags
20+
from django.utils.translation import gettext as _
21+
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest
1722

1823
from lms.djangoapps.courseware.tabs import get_course_tab_list
1924
from lms.djangoapps.instructor import permissions
20-
from lms.djangoapps.instructor.views.api import get_student_from_identifier
25+
from lms.djangoapps.instructor.views.api import _display_unit, get_student_from_identifier
2126
from lms.djangoapps.instructor.views.instructor_task_helpers import extract_task_features
2227
from lms.djangoapps.instructor_task import api as task_api
2328
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
2429
from openedx.core.lib.courses import get_course_by_id
2530
from .serializers_v2 import (
2631
InstructorTaskListSerializer,
27-
CourseInformationSerializer
32+
CourseInformationSerializer,
33+
BlockDueDateSerializerV2,
34+
)
35+
from .tools import (
36+
find_unit,
37+
set_due_date_extension,
2838
)
2939

3040
log = logging.getLogger(__name__)
@@ -269,3 +279,45 @@ def get(self, request, course_id):
269279
tasks_data = [extract_task_features(task) for task in tasks]
270280
serializer = InstructorTaskListSerializer({'tasks': tasks_data})
271281
return Response(serializer.data, status=status.HTTP_200_OK)
282+
283+
284+
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
285+
class ChangeDueDateView(APIView):
286+
"""
287+
Grants a due date extension to a student for a particular unit.
288+
this version works with a new payload that is JSON and more up to date.
289+
"""
290+
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
291+
permission_name = permissions.GIVE_STUDENT_EXTENSION
292+
serializer_class = BlockDueDateSerializerV2
293+
294+
def post(self, request, course_id):
295+
"""
296+
Grants a due date extension to a learner for a particular unit.
297+
298+
params:
299+
blockId (str): The URL related to the block that needs the due date update.
300+
due_datetime (str): The new due date and time for the block.
301+
email_or_username (str): The email or username of the learner whose access is being modified.
302+
"""
303+
serializer_data = self.serializer_class(data=request.data)
304+
if not serializer_data.is_valid():
305+
return JsonResponseBadRequest({'error': serializer_data.errors})
306+
307+
learner = serializer_data.validated_data.get('email_or_username')
308+
due_date = serializer_data.validated_data.get('due_datetime')
309+
course = get_course_by_id(CourseKey.from_string(course_id))
310+
unit = find_unit(course, serializer_data.validated_data.get('block_id'))
311+
reason = strip_tags(serializer_data.validated_data.get('reason', ''))
312+
try:
313+
set_due_date_extension(course, unit, learner, due_date, request.user, reason=reason)
314+
except Exception as error: # pylint: disable=broad-except
315+
return JsonResponseBadRequest({'error': str(error)})
316+
317+
return JsonResponse(
318+
{
319+
'message': _(
320+
'Successfully changed due date for learner {0} for {1} '
321+
'to {2}').
322+
format(learner.profile.name, _display_unit(unit), due_date.strftime('%Y-%m-%d %H:%M')
323+
)})

lms/djangoapps/instructor/views/serializers_v2.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
3131
from xmodule.modulestore.django import modulestore
3232

33+
from .tools import get_student_from_identifier, parse_datetime, DashboardError
34+
3335

3436
class CourseInformationSerializer(serializers.Serializer):
3537
"""
@@ -337,3 +339,46 @@ class InstructorTaskSerializer(serializers.Serializer):
337339

338340
class InstructorTaskListSerializer(serializers.Serializer):
339341
tasks = InstructorTaskSerializer(many=True)
342+
343+
344+
class BlockDueDateSerializerV2(serializers.Serializer):
345+
"""
346+
Serializer for handling block due date updates for a specific student.
347+
Fields:
348+
block_id (str): The ID related to the block that needs the due date update.
349+
due_datetime (str): The new due date and time for the block.
350+
email_or_username (str): The email or username of the student whose access is being modified.
351+
reason (str): Reason why updating this.
352+
"""
353+
block_id = serializers.CharField()
354+
due_datetime = serializers.CharField()
355+
email_or_username = serializers.CharField(
356+
max_length=255,
357+
help_text="Email or username of user to change access"
358+
)
359+
reason = serializers.CharField(required=False)
360+
361+
def validate_email_or_username(self, value):
362+
"""
363+
Validate that the email_or_username corresponds to an existing user.
364+
"""
365+
try:
366+
user = get_student_from_identifier(value)
367+
except Exception as exc:
368+
raise serializers.ValidationError(
369+
_('Invalid learner identifier: {0}').format(value)
370+
) from exc
371+
372+
return user
373+
374+
def validate_due_datetime(self, value):
375+
"""
376+
Validate and parse the due_datetime string into a datetime object.
377+
"""
378+
try:
379+
parsed_date = parse_datetime(value)
380+
return parsed_date
381+
except DashboardError as exc:
382+
raise serializers.ValidationError(
383+
_('The extension due date and time format is incorrect')
384+
) from exc

0 commit comments

Comments
 (0)