Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6c87142
chore: base work for unit extensions v2 api
holaontiveros Dec 17, 2025
a2e3566
chore: fix pylint things
holaontiveros Dec 17, 2025
dd4b7eb
chore: update to match edx-when comments
holaontiveros Jan 8, 2026
43deb2a
chore: update edx_when version
holaontiveros Jan 9, 2026
7a98a89
chore: restore old version before upgrade with make
holaontiveros Jan 9, 2026
4913b98
chore: update with make upgrade-package package=edx-when
holaontiveros Jan 9, 2026
55eef0e
chore: exclude extras from get_overrides_for_block to prevent tuple f…
holaontiveros Jan 12, 2026
1b9a193
Merge branch 'master' into feature/unit_extensions_v2
holaontiveros Jan 12, 2026
0e9ea9e
Merge branch 'master' into feature/unit_extensions_v2
dwong2708 Jan 15, 2026
9c95a16
feat: address review comments
dwong2708 Jan 17, 2026
000a598
Merge remote-tracking branch 'upstream/master' into feature/unit_exte…
dwong2708 Jan 20, 2026
d31fd52
chore: fixed lint issue
holaontiveros Jan 20, 2026
9395de4
Merge remote-tracking branch 'upstream/master' into feature/unit_exte…
dwong2708 Jan 20, 2026
c94aa75
fix: apply review feedback to unit extensions tests
dwong2708 Jan 22, 2026
bb3d608
Merge remote-tracking branch 'upstream/master' into feature/unit_exte…
dwong2708 Jan 22, 2026
41cb3fa
refactor: simplify result filtering
dwong2708 Jan 26, 2026
05fe9be
Merge remote-tracking branch 'upstream/master' into feature/unit_exte…
dwong2708 Jan 26, 2026
a20c206
test: update test_get_unit_extensions
dwong2708 Jan 26, 2026
90896c4
build: Update README, CODEOWNERS, catalog-info with new repo name (#3…
kdmccormick Jan 26, 2026
3416ce1
Merge branch 'master' into feature/unit_extensions_v2
dwong2708 Jan 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
428 changes: 428 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 @@ -41,6 +41,11 @@
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'
),
re_path(
rf'^courses/{COURSE_ID_PATTERN}/ora$',
api_v2.ORAView.as_view(),
Expand Down
163 changes: 163 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 @@ -34,6 +38,7 @@
InstructorTaskListSerializer,
CourseInformationSerializerV2,
BlockDueDateSerializerV2,
UnitExtensionSerializer,
ORASerializer,
ORASummarySerializer,
)
Expand Down Expand Up @@ -356,6 +361,164 @@ def get(self, request, course_id):
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": "john.doe@example.com",
"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
filter_backends = []

def _matches_email_or_username(self, unit_extension, filter_value):
"""
Check if the unit extension matches the email or username filter.
"""
return (
filter_value in unit_extension.username.lower()
or filter_value in unit_extension.email.lower()
)

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)
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 if any
filter_value = email_or_username_filter.lower() if email_or_username_filter else None

results = [
extension
for extension in unit_due_date_extensions
if self._matches_email_or_username(extension, filter_value)
] if filter_value else unit_due_date_extensions # if no filter, use all

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

return results


class ORAView(GenericAPIView):
"""
View to list all Open Response Assessments (ORAs) for a given course.
Expand Down
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 @@ -418,6 +418,33 @@ def validate_due_datetime(self, value):
) 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"
)


class ORASerializer(serializers.Serializer):
"""Serializer for Open Response Assessments (ORAs) in a course."""

Expand Down
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 @@ -542,7 +542,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 @@ -850,7 +850,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 @@ -634,7 +634,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 @@ -658,7 +658,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