Skip to content
Open
478 changes: 478 additions & 0 deletions lms/djangoapps/instructor/tests/test_api_v2.py

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions lms/djangoapps/instructor/views/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
rf'^courses/{COURSE_ID_PATTERN}/graded_subsections$',
api_v2.GradedSubsectionsView.as_view(),
name='graded_subsections'
),
re_path(
rf'^courses/{COURSE_ID_PATTERN}/unit_extensions$',
api_v2.UnitExtensionsView.as_view(),
name='unit_extensions'
)
]

Expand Down
158 changes: 158 additions & 0 deletions lms/djangoapps/instructor/views/api_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@

import logging

from dataclasses import dataclass
from typing import Optional, Tuple
import edx_api_doc_tools as apidocs
from edx_when import api as edx_when_api
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from rest_framework import status
from rest_framework.generics import ListAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
Expand All @@ -31,6 +35,7 @@
InstructorTaskListSerializer,
CourseInformationSerializerV2,
BlockDueDateSerializerV2,
UnitExtensionSerializer,
)
from .tools import (
find_unit,
Expand Down Expand Up @@ -349,3 +354,156 @@ def get(self, request, course_id):
} for unit in graded_subsections]}

return Response(formated_subsections, status=status.HTTP_200_OK)


@dataclass(frozen=True)
class UnitDueDateExtension:
"""Dataclass representing a unit due date extension for a student."""

username: str
full_name: str
email: str
unit_title: str
unit_location: str
extended_due_date: Optional[str]

@classmethod
def from_block_tuple(cls, row: Tuple, unit):
username, full_name, due_date, email, location = row
unit_title = title_or_url(unit)
return cls(
username=username,
full_name=full_name,
email=email,
unit_title=unit_title,
unit_location=location,
extended_due_date=due_date,
)

@classmethod
def from_course_tuple(cls, row: Tuple, units_dict: dict):
username, full_name, email, location, due_date = row
unit_title = title_or_url(units_dict[str(location)])
return cls(
username=username,
full_name=full_name,
email=email,
unit_title=unit_title,
unit_location=location,
extended_due_date=due_date,
)


class UnitExtensionsView(ListAPIView):
"""
Retrieve a paginated list of due date extensions for units in a course.

**Example Requests**

GET /api/instructor/v2/courses/{course_id}/unit_extensions
GET /api/instructor/v2/courses/{course_id}/unit_extensions?page=2
GET /api/instructor/v2/courses/{course_id}/unit_extensions?page_size=50
GET /api/instructor/v2/courses/{course_id}/unit_extensions?email_or_username=john
GET /api/instructor/v2/courses/{course_id}/unit_extensions?block_id=block-v1:org@problem+block@unit1

**Response Values**

{
"count": 150,
"next": "http://example.com/api/instructor/v2/courses/course-v1:org+course+run/unit_extensions?page=2",
"previous": null,
"results": [
{
"username": "student1",
"full_name": "John Doe",
"email": "[email protected]",
"unit_title": "Unit 1: Introduction",
"unit_location": "block-v1:org+course+run+type@problem+block@unit1",
"extended_due_date": "2023-12-25T23:59:59Z"
},
...
]
}

**Parameters**

course_id: Course key for the course.
page (optional): Page number for pagination.
page_size (optional): Number of results per page.

**Returns**

* 200: OK - Returns paginated list of unit extensions
* 401: Unauthorized - User is not authenticated
* 403: Forbidden - User lacks instructor permissions
* 404: Not Found - Course does not exist
"""
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_name = permissions.VIEW_DASHBOARD
serializer_class = UnitExtensionSerializer

def get_queryset(self):
"""
Returns the queryset of unit extensions for the specified course.

This method uses the core logic from get_overrides_for_course to retrieve
due date extension data and transforms it into a list of normalized objects
that can be paginated and serialized.

Supports filtering by:
- email_or_username: Filter by username or email address
- block_id: Filter by specific unit/subsection location
"""
course_id = self.kwargs["course_id"]
course_key = CourseKey.from_string(course_id)
course = get_course_by_id(course_key)

email_or_username_filter = self.request.query_params.get("email_or_username")
block_id_filter = self.request.query_params.get("block_id")

units = get_units_with_due_date(course)
units_dict = {str(u.location): u for u in units}

# Fetch and normalize overrides
if block_id_filter:
try:
unit = find_unit(course, block_id_filter)
query_data = edx_when_api.get_overrides_for_block(course.id, unit.location)
unit_due_date_extensions = [
UnitDueDateExtension.from_block_tuple(row, unit)
for row in query_data
]
except InvalidKeyError:
# If block_id is invalid, return empty list
unit_due_date_extensions = []
else:
query_data = edx_when_api.get_overrides_for_course(course.id)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If get_overrides_for_course had the same return structure as get_overrides_for_block, I feel like we could clean up the duplication in the if/else blocks. Is it worth adding something (maybe a get_overrides_for_course_v2) to edx-when that does that? Since the two blocks are fairly close, are we able to combine them somehow?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the suggestion.

We might need an adapter function like this o normalize both responses into the same structure:

def normalize_override_tuple(source, row):
    if source == "block":
        username, fullname, due_date, email, location = row
    elif source == "course":
        username, fullname, email, location, due_date = row
    else:
        raise ValueError("Unknown source")

    return {
        "username": username,
        "full_name": fullname,
        "email": email,
        "location": location,
        "due_date": due_date,
    }

and the usage could be:

query_data = edx_when_api.get_overrides_for_block(course.id, unit.location)
extension_data = [normalize_override_tuple("block", row) for row in query_data]
query_data = edx_when_api.get_overrides_for_course(course.id)
extension_data = [normalize_override_tuple("course", row) for row in query_data]

unit_due_date_extensions = [
UnitDueDateExtension.from_course_tuple(row, units_dict)
for row in query_data
if str(row[3]) in units_dict # Ensure unit has due date
]

# Apply filters
results = []
filter_value = email_or_username_filter.lower() if email_or_username_filter else None

for unit_due_date_extension in unit_due_date_extensions:
# Optional username/email filter
if filter_value:
if (
filter_value not in unit_due_date_extension.username.lower()
and filter_value not in unit_due_date_extension.email.lower()
):
continue
results.append(unit_due_date_extension)

# Sort for consistent ordering
results.sort(
key=lambda o: (
o.username,
o.unit_title,
)
)

return results
27 changes: 27 additions & 0 deletions lms/djangoapps/instructor/views/serializers_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,30 @@ def validate_due_datetime(self, value):
raise serializers.ValidationError(
_('The extension due date and time format is incorrect')
) from exc


class UnitExtensionSerializer(serializers.Serializer):
"""
Serializer for unit extension data.

This serializer formats the data returned by get_overrides_for_course
for the paginated list API endpoint.
"""
username = serializers.CharField(
help_text="Username of the learner who has the extension"
)
full_name = serializers.CharField(
help_text="Full name of the learner"
)
email = serializers.EmailField(
help_text="Email address of the learner"
)
unit_title = serializers.CharField(
help_text="Display name or URL of the unit"
)
unit_location = serializers.CharField(
help_text="Block location/ID of the unit"
)
extended_due_date = serializers.DateTimeField(
help_text="The extended due date for the learner"
)
2 changes: 1 addition & 1 deletion lms/djangoapps/instructor/views/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def dump_block_extensions(course, unit):
"""
header = [_("Username"), _("Full Name"), _("Extended Due Date")]
data = []
for username, fullname, due_date in api.get_overrides_for_block(course.id, unit.location):
for username, fullname, due_date, *unused in api.get_overrides_for_block(course.id, unit.location):
due_date = due_date.strftime('%Y-%m-%d %H:%M')
data.append(dict(list(zip(header, (username, fullname, due_date)))))
data.sort(key=operator.itemgetter(_("Username")))
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ edx-toggles==5.4.1
# edxval
# event-tracking
# ora2
edx-when==3.0.0
edx-when==3.1.0
# via
# -r requirements/edx/kernel.in
# edx-proctoring
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ edx-toggles==5.4.1
# edxval
# event-tracking
# ora2
edx-when==3.0.0
edx-when==3.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ edx-toggles==5.4.1
# edxval
# event-tracking
# ora2
edx-when==3.0.0
edx-when==3.1.0
# via
# -r requirements/edx/base.txt
# edx-proctoring
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ edx-toggles==5.4.1
# edxval
# event-tracking
# ora2
edx-when==3.0.0
edx-when==3.1.0
# via
# -r requirements/edx/base.txt
# edx-proctoring
Expand Down
Loading