Skip to content

Commit 12029c7

Browse files
feat: add unit extensions list endpoint for instructor dashboard v2 api (#37783)
Add a new endpoint to get the unit extensions for a course. Update edx-when from 3.0.0 to 3.1.0 in order to get data necessary for response. --------- Co-authored-by: Daniel Wong <danieleduardo.wongfa@wgu.edu>
1 parent 56cdab9 commit 12029c7

9 files changed

Lines changed: 628 additions & 5 deletions

File tree

lms/djangoapps/instructor/tests/test_api_v2.py

Lines changed: 428 additions & 0 deletions
Large diffs are not rendered by default.

lms/djangoapps/instructor/views/api_urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
api_v2.GradedSubsectionsView.as_view(),
4242
name='graded_subsections'
4343
),
44+
re_path(
45+
rf'^courses/{COURSE_ID_PATTERN}/unit_extensions$',
46+
api_v2.UnitExtensionsView.as_view(),
47+
name='unit_extensions'
48+
),
4449
re_path(
4550
rf'^courses/{COURSE_ID_PATTERN}/ora$',
4651
api_v2.ORAView.as_view(),

lms/djangoapps/instructor/views/api_v2.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77

88
import logging
99

10+
from dataclasses import dataclass
11+
from typing import Optional, Tuple
1012
import edx_api_doc_tools as apidocs
13+
from edx_when import api as edx_when_api
1114
from opaque_keys import InvalidKeyError
1215
from opaque_keys.edx.keys import CourseKey, UsageKey
1316
from rest_framework import status
17+
from rest_framework.generics import ListAPIView
1418
from rest_framework.permissions import IsAuthenticated
1519
from rest_framework.response import Response
1620
from rest_framework.views import APIView
@@ -34,6 +38,7 @@
3438
InstructorTaskListSerializer,
3539
CourseInformationSerializerV2,
3640
BlockDueDateSerializerV2,
41+
UnitExtensionSerializer,
3742
ORASerializer,
3843
ORASummarySerializer,
3944
)
@@ -356,6 +361,164 @@ def get(self, request, course_id):
356361
return Response(formated_subsections, status=status.HTTP_200_OK)
357362

358363

364+
@dataclass(frozen=True)
365+
class UnitDueDateExtension:
366+
"""Dataclass representing a unit due date extension for a student."""
367+
368+
username: str
369+
full_name: str
370+
email: str
371+
unit_title: str
372+
unit_location: str
373+
extended_due_date: Optional[str]
374+
375+
@classmethod
376+
def from_block_tuple(cls, row: Tuple, unit):
377+
username, full_name, due_date, email, location = row
378+
unit_title = title_or_url(unit)
379+
return cls(
380+
username=username,
381+
full_name=full_name,
382+
email=email,
383+
unit_title=unit_title,
384+
unit_location=location,
385+
extended_due_date=due_date,
386+
)
387+
388+
@classmethod
389+
def from_course_tuple(cls, row: Tuple, units_dict: dict):
390+
username, full_name, email, location, due_date = row
391+
unit_title = title_or_url(units_dict[str(location)])
392+
return cls(
393+
username=username,
394+
full_name=full_name,
395+
email=email,
396+
unit_title=unit_title,
397+
unit_location=location,
398+
extended_due_date=due_date,
399+
)
400+
401+
402+
class UnitExtensionsView(ListAPIView):
403+
"""
404+
Retrieve a paginated list of due date extensions for units in a course.
405+
406+
**Example Requests**
407+
408+
GET /api/instructor/v2/courses/{course_id}/unit_extensions
409+
GET /api/instructor/v2/courses/{course_id}/unit_extensions?page=2
410+
GET /api/instructor/v2/courses/{course_id}/unit_extensions?page_size=50
411+
GET /api/instructor/v2/courses/{course_id}/unit_extensions?email_or_username=john
412+
GET /api/instructor/v2/courses/{course_id}/unit_extensions?block_id=block-v1:org@problem+block@unit1
413+
414+
**Response Values**
415+
416+
{
417+
"count": 150,
418+
"next": "http://example.com/api/instructor/v2/courses/course-v1:org+course+run/unit_extensions?page=2",
419+
"previous": null,
420+
"results": [
421+
{
422+
"username": "student1",
423+
"full_name": "John Doe",
424+
"email": "john.doe@example.com",
425+
"unit_title": "Unit 1: Introduction",
426+
"unit_location": "block-v1:org+course+run+type@problem+block@unit1",
427+
"extended_due_date": "2023-12-25T23:59:59Z"
428+
},
429+
...
430+
]
431+
}
432+
433+
**Parameters**
434+
435+
course_id: Course key for the course.
436+
page (optional): Page number for pagination.
437+
page_size (optional): Number of results per page.
438+
439+
**Returns**
440+
441+
* 200: OK - Returns paginated list of unit extensions
442+
* 401: Unauthorized - User is not authenticated
443+
* 403: Forbidden - User lacks instructor permissions
444+
* 404: Not Found - Course does not exist
445+
"""
446+
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
447+
permission_name = permissions.VIEW_DASHBOARD
448+
serializer_class = UnitExtensionSerializer
449+
filter_backends = []
450+
451+
def _matches_email_or_username(self, unit_extension, filter_value):
452+
"""
453+
Check if the unit extension matches the email or username filter.
454+
"""
455+
return (
456+
filter_value in unit_extension.username.lower()
457+
or filter_value in unit_extension.email.lower()
458+
)
459+
460+
def get_queryset(self):
461+
"""
462+
Returns the queryset of unit extensions for the specified course.
463+
464+
This method uses the core logic from get_overrides_for_course to retrieve
465+
due date extension data and transforms it into a list of normalized objects
466+
that can be paginated and serialized.
467+
468+
Supports filtering by:
469+
- email_or_username: Filter by username or email address
470+
- block_id: Filter by specific unit/subsection location
471+
"""
472+
course_id = self.kwargs["course_id"]
473+
course_key = CourseKey.from_string(course_id)
474+
course = get_course_by_id(course_key)
475+
476+
email_or_username_filter = self.request.query_params.get("email_or_username")
477+
block_id_filter = self.request.query_params.get("block_id")
478+
479+
units = get_units_with_due_date(course)
480+
units_dict = {str(u.location): u for u in units}
481+
482+
# Fetch and normalize overrides
483+
if block_id_filter:
484+
try:
485+
unit = find_unit(course, block_id_filter)
486+
query_data = edx_when_api.get_overrides_for_block(course.id, unit.location)
487+
unit_due_date_extensions = [
488+
UnitDueDateExtension.from_block_tuple(row, unit)
489+
for row in query_data
490+
]
491+
except InvalidKeyError:
492+
# If block_id is invalid, return empty list
493+
unit_due_date_extensions = []
494+
else:
495+
query_data = edx_when_api.get_overrides_for_course(course.id)
496+
unit_due_date_extensions = [
497+
UnitDueDateExtension.from_course_tuple(row, units_dict)
498+
for row in query_data
499+
if str(row[3]) in units_dict # Ensure unit has due date
500+
]
501+
502+
# Apply filters if any
503+
filter_value = email_or_username_filter.lower() if email_or_username_filter else None
504+
505+
results = [
506+
extension
507+
for extension in unit_due_date_extensions
508+
if self._matches_email_or_username(extension, filter_value)
509+
] if filter_value else unit_due_date_extensions # if no filter, use all
510+
511+
# Sort for consistent ordering
512+
results.sort(
513+
key=lambda o: (
514+
o.username,
515+
o.unit_title,
516+
)
517+
)
518+
519+
return results
520+
521+
359522
class ORAView(GenericAPIView):
360523
"""
361524
View to list all Open Response Assessments (ORAs) for a given course.

lms/djangoapps/instructor/views/serializers_v2.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,33 @@ def validate_due_datetime(self, value):
418418
) from exc
419419

420420

421+
class UnitExtensionSerializer(serializers.Serializer):
422+
"""
423+
Serializer for unit extension data.
424+
425+
This serializer formats the data returned by get_overrides_for_course
426+
for the paginated list API endpoint.
427+
"""
428+
username = serializers.CharField(
429+
help_text="Username of the learner who has the extension"
430+
)
431+
full_name = serializers.CharField(
432+
help_text="Full name of the learner"
433+
)
434+
email = serializers.EmailField(
435+
help_text="Email address of the learner"
436+
)
437+
unit_title = serializers.CharField(
438+
help_text="Display name or URL of the unit"
439+
)
440+
unit_location = serializers.CharField(
441+
help_text="Block location/ID of the unit"
442+
)
443+
extended_due_date = serializers.DateTimeField(
444+
help_text="The extended due date for the learner"
445+
)
446+
447+
421448
class ORASerializer(serializers.Serializer):
422449
"""Serializer for Open Response Assessments (ORAs) in a course."""
423450

lms/djangoapps/instructor/views/tools.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def dump_block_extensions(course, unit):
220220
"""
221221
header = [_("Username"), _("Full Name"), _("Extended Due Date")]
222222
data = []
223-
for username, fullname, due_date in api.get_overrides_for_block(course.id, unit.location):
223+
for username, fullname, due_date, *unused in api.get_overrides_for_block(course.id, unit.location):
224224
due_date = due_date.strftime('%Y-%m-%d %H:%M')
225225
data.append(dict(list(zip(header, (username, fullname, due_date)))))
226226
data.sort(key=operator.itemgetter(_("Username")))

requirements/edx/base.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ edx-toggles==5.4.1
542542
# edxval
543543
# event-tracking
544544
# ora2
545-
edx-when==3.0.0
545+
edx-when==3.1.0
546546
# via
547547
# -r requirements/edx/kernel.in
548548
# edx-proctoring

requirements/edx/development.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ edx-toggles==5.4.1
850850
# edxval
851851
# event-tracking
852852
# ora2
853-
edx-when==3.0.0
853+
edx-when==3.1.0
854854
# via
855855
# -r requirements/edx/doc.txt
856856
# -r requirements/edx/testing.txt

requirements/edx/doc.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,7 @@ edx-toggles==5.4.1
634634
# edxval
635635
# event-tracking
636636
# ora2
637-
edx-when==3.0.0
637+
edx-when==3.1.0
638638
# via
639639
# -r requirements/edx/base.txt
640640
# edx-proctoring

requirements/edx/testing.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ edx-toggles==5.4.1
658658
# edxval
659659
# event-tracking
660660
# ora2
661-
edx-when==3.0.0
661+
edx-when==3.1.0
662662
# via
663663
# -r requirements/edx/base.txt
664664
# edx-proctoring

0 commit comments

Comments
 (0)