Merge pull request #31491 from open-craft/farhaan/upstream-submission-history-api

feat: Added submission history API and extended enrollment API to support grading and finished flags.
This commit is contained in:
Piotr Surowiec
2023-02-17 14:21:19 +01:00
committed by GitHub
5 changed files with 738 additions and 10 deletions

View File

@@ -2,20 +2,23 @@
Tests for v1 views
"""
from collections import OrderedDict
from unittest.mock import MagicMock, patch
from urllib.parse import urlencode
import ddt
from django.db import connections
from django.urls import reverse
from opaque_keys import InvalidKeyError
from rest_framework import status
from rest_framework.test import APITestCase
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.student.tests.factories import GlobalStaffFactory, UserFactory
from lms.djangoapps.courseware.tests.test_submitting_problems import TestSubmittingProblems
from lms.djangoapps.grades.rest_api.v1.tests.mixins import GradeViewTestMixin
from lms.djangoapps.grades.rest_api.v1.views import CourseGradesView
from openedx.core.djangoapps.user_authn.tests.utils import AuthAndScopesTestMixin
from xmodule.modulestore.tests.factories import BlockFactory
@ddt.ddt
@@ -257,3 +260,355 @@ class CourseGradesViewTest(GradeViewTestMixin, APITestCase):
])
assert expected_data == resp.data
class SectionGradesBreakdownTest(GradeViewTestMixin, APITestCase):
"""
Tests for course grading status for all users in a course
e.g. /api/grades/v1/section_grades_breakdown
/api/grades/v1/section_grades_breakdown/?course_id={course_id}
/api/grades/v1/section_grades_breakdown/?course_id={course_id}&username={username}
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.namespaced_url = 'grades_api:v1:section_grades_breakdown'
cls.section_breakdown = (
[
{
'category': 'Homework',
'detail': f'Homework {i} Unreleased - 0% (?/?)',
'label': f'HW {i:02d}', 'percent': .0
}
for i in range(1, 11)
]
+ [
{
'category': 'Homework',
'detail': 'Homework 11 Unreleased - 0% (?/?)',
'label': 'HW 11',
'mark': {'detail': 'The lowest 2 Homework scores are dropped.'},
'percent': 0.0
},
{
'category': 'Homework',
'detail': 'Homework 12 Unreleased - 0% (?/?)',
'label': 'HW 12',
'mark': {'detail': 'The lowest 2 Homework scores are dropped.'},
'percent': 0.0
}
]
+ [
{
'category': 'Homework',
'detail': 'Homework Average = 0%',
'label': 'HW Avg', 'percent': 0.0,
'prominent': True
}
]
+ [
{
'category': 'Lab',
'detail': f'Lab {i} Unreleased - 0% (?/?)',
'label': f'Lab {i:02d}', 'percent': .0
}
for i in range(1, 11)
]
+ [
{
'category': 'Lab',
'detail': 'Lab 11 Unreleased - 0% (?/?)',
'label': 'Lab 11',
'mark': {'detail': 'The lowest 2 Lab scores are dropped.'},
'percent': 0.0
},
{
'category': 'Lab',
'detail': 'Lab 12 Unreleased - 0% (?/?)',
'label': 'Lab 12',
'mark': {'detail': 'The lowest 2 Lab scores are dropped.'},
'percent': 0.0
},
{
'category': 'Lab',
'detail': 'Lab Average = 0%',
'label': 'Lab Avg',
'percent': 0.0,
'prominent': True
},
{
'category': 'Midterm Exam',
'detail': 'Midterm Exam = 0%',
'label': 'Midterm',
'percent': 0.0,
'prominent': True
},
{
'category': 'Final Exam',
'detail': 'Final Exam = 0%',
'label': 'Final',
'percent': 0.0,
'prominent': True
}
]
)
def get_url(self, query_params=None):
"""
Helper function to create the url
"""
base_url = reverse(
self.namespaced_url,
)
if query_params:
base_url = f'{base_url}?{query_params}'
return base_url
def test_anonymous(self):
resp = self.client.get(self.get_url())
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
def test_student(self):
self.client.login(username=self.student.username, password=self.password)
resp = self.client.get(self.get_url())
assert resp.status_code == status.HTTP_403_FORBIDDEN
def test_course_does_not_exist(self):
self.client.login(username=self.global_staff.username, password=self.password)
resp = self.client.get(
self.get_url(
urlencode({'course_id': 'course-v1:MITx+8.MechCX+2014_T1'})
)
)
expected_data = OrderedDict(
[
('next', None),
('previous', None),
('results', [])
]
)
assert resp.status_code == status.HTTP_200_OK
assert expected_data == resp.data
def test_course_no_enrollments(self):
self.client.login(username=self.global_staff.username, password=self.password)
resp = self.client.get(
self.get_url(urlencode({'course_id': self.empty_course.id}))
)
assert resp.status_code == status.HTTP_200_OK
expected_data = OrderedDict(
[
('next', None),
('previous', None),
('results', []),
]
)
assert expected_data == resp.data
def test_staff_can_get_all_grades(self):
self.client.login(username=self.global_staff.username, password=self.password)
resp = self.client.get(self.get_url(urlencode({'course_id': self.course_key})))
# This should have permission to access this API endpoint.
assert resp.status_code == status.HTTP_200_OK
expected_data = OrderedDict(
[
('next', None),
('previous', None),
(
'results',
[
{
'course_id': str(self.course_key),
'current_grade': 0,
'passed': False,
'section_breakdown': self.section_breakdown,
'username': 'student'
},
{
'course_id': str(self.course_key),
'current_grade': 0,
'passed': False,
'section_breakdown': self.section_breakdown,
'username': 'other_student'
},
{
'course_id': str(self.course_key),
'current_grade': 0,
'passed': False,
'section_breakdown': self.section_breakdown,
'username': 'program_student'
},
{
'course_id': str(self.course_key),
'current_grade': 0,
'passed': False,
'section_breakdown': self.section_breakdown,
'username': 'program_masters_student'
}
]
)
]
)
assert expected_data == resp.data
def test_staff_can_get_all_grades_for_user(self):
self.client.login(username=self.global_staff.username, password=self.password)
resp = self.client.get(self.get_url(urlencode({'course_id': self.course_key,
'username': 'student'})))
# this should have permission to access this API endpoint
assert resp.status_code == status.HTTP_200_OK
expected_data = OrderedDict(
[
('next', None),
('previous', None),
(
'results',
[
{
'course_id': str(self.course_key),
'current_grade': 0,
'passed': False,
'section_breakdown': self.section_breakdown,
'username': 'student'
}
]
)
]
)
assert expected_data == resp.data
class CourseSubmissionHistoryTest(GradeViewTestMixin, APITestCase):
"""
Tests for course submission history for all users in a course
e.g. /api/grades/v1/submission_history/{course_id}
/api/grades/v1/submission_history/{course_id}/?username={username}
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.namespaced_url = 'grades_api:v1:submission_history'
def get_url(self, course_key, query_params=None):
"""
Helper function to create the url
"""
base_url = reverse(
self.namespaced_url,
kwargs={
'course_id': course_key,
}
)
if query_params:
base_url = f'{base_url}?{query_params}'
return base_url
def test_anonymous(self):
resp = self.client.get(self.get_url(course_key=self.course_key))
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
def test_student(self):
self.client.login(username=self.student.username, password=self.password)
resp = self.client.get(self.get_url(course_key=self.course_key))
assert resp.status_code == status.HTTP_403_FORBIDDEN
def test_course_does_not_exist(self):
self.client.login(username=self.global_staff.username, password=self.password)
resp = self.client.get(
self.get_url(course_key='course-v1:MITx+8.MechCX+2014_T1')
)
expected_data = OrderedDict([('next', None), ('previous', None), ('results', [])])
assert resp.status_code == status.HTTP_200_OK
assert expected_data == resp.data
def test_course_no_enrollments(self):
self.client.login(username=self.global_staff.username, password=self.password)
resp = self.client.get(
self.get_url(course_key=self.empty_course.id)
)
assert resp.status_code == status.HTTP_200_OK
expected_data = OrderedDict([('next', None), ('previous', None), ('results', [])])
assert expected_data == resp.data
class CourseSubmissionHistoryWithDataTest(TestSubmittingProblems):
"""
Tests for course submission history for all users in a course
e.g. /api/grades/v1/submission_history/?course_id={course_id}
/api/grades/v1/submission_history?course_id={course_id}&username={username}
"""
# Tell Django to clean out all databases, not just default
databases = set(connections)
def setUp(self):
super().setUp()
self.namespaced_url = 'grades_api:v1:submission_history'
self.password = 'test'
self.basic_setup()
self.global_staff = GlobalStaffFactory.create()
def basic_setup(self, late=False, reset=False, showanswer=False):
"""
Set up a simple course for testing basic grading functionality.
"""
grading_policy = {
"GRADER": [{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "HW",
"weight": 1.0
}],
"GRADE_CUTOFFS": {
'A': .9,
'B': .33
}
}
self.add_grading_policy(grading_policy)
# set up a simple course with four problems
homework = self.add_graded_section_to_course('homework', late=late, reset=reset, showanswer=showanswer)
vertical = BlockFactory.create(
parent_location=homework.location,
category='vertical',
display_name='Subsection 1',
)
self.add_dropdown_to_section(vertical.location, 'p1', 1)
self.add_dropdown_to_section(vertical.location, 'p2', 1)
self.add_dropdown_to_section(vertical.location, 'p3', 1)
self.refresh_course()
def get_url(self, course_key, query_params=None):
"""
Helper function to create the url
"""
base_url = reverse(
self.namespaced_url,
kwargs={
'course_id': course_key,
}
)
if query_params:
base_url = f'{base_url}?{query_params}'
return base_url
def test_course_exist_with_data(self):
self.submit_question_answer('p1', {'2_1': 'Correct'})
self.client.login(username=self.global_staff.username, password=self.password)
resp = self.client.get(
self.get_url(
course_key=self.course.id,
)
)
assert resp.status_code == status.HTTP_200_OK
resp_json = resp.json()['results'][0]
assert resp_json['course_id'] == str(self.course.id)
assert resp_json['course_name'] == 'test_course'
assert len(resp_json['problems']) > 0
assert len(resp_json['problems'][0]['submission_history']) > 0

View File

@@ -44,4 +44,14 @@ urlpatterns = [
gradebook_views.SubsectionGradeView.as_view(),
name='course_grade_overrides'
),
path(
'section_grades_breakdown/',
views.SectionGradesBreakdown.as_view(),
name='section_grades_breakdown'
),
re_path(
fr'submission_history/{settings.COURSE_ID_PATTERN}/',
views.SubmissionHistoryView.as_view(),
name='submission_history'
),
]

View File

@@ -58,6 +58,7 @@ class GradeViewMixin(DeveloperErrorViewMixin):
"""
Mixin class for Grades related views.
"""
def _get_single_user(self, request, course_key, user_id=None):
"""
Returns a single USER_MODEL object corresponding to either the user_id provided, or if no id is provided,
@@ -141,13 +142,47 @@ class GradeViewMixin(DeveloperErrorViewMixin):
Returns:
A list of users, pulled from a paginated queryset of enrollments, who are enrolled in the given course.
"""
paged_enrollments = self._paginate_course_enrollment(course_key,
course_enrollment_filter, related_models, annotations)
return GradeViewMixin._get_enrolled_users(paged_enrollments)
@staticmethod
def _get_enrolled_users(enrollments):
"""
Args:
enrollments: CourseEnrollment query set.
Returns:
A list of users, pulled from the CourseEnrollment query set.
"""
retlist = []
for enrollment in enrollments:
enrollment.user.enrollment_mode = enrollment.mode
retlist.append(enrollment.user)
return retlist
def _paginate_course_enrollment(self, course_key=None,
course_enrollment_filter=None, related_models=None, annotations=None):
"""
Args:
course_key (CourseLocator): The course to retrieve grades for.
course_enrollment_filter: Optional list of Q objects to pass
to `CourseEnrollment.filter()`.
related_models: Optional list of related models to join to the CourseEnrollment table.
annotations: Optional dict of fields to add to the queryset via annotation
Returns:
A list of Enrollments, pulled from a paginated queryset.
"""
queryset = CourseEnrollment.objects
if annotations:
queryset = queryset.annotate(**annotations)
filter_args = [
Q(course_id=course_key) & Q(is_active=True)
]
filter_args = [Q(is_active=True)]
if course_key:
filter_args = [Q(course_id=course_key) & Q(is_active=True)]
filter_args.extend(course_enrollment_filter or [])
enrollments_in_course = use_read_replica_if_available(
@@ -157,11 +192,7 @@ class GradeViewMixin(DeveloperErrorViewMixin):
enrollments_in_course = enrollments_in_course.select_related(*related_models)
paged_enrollments = self.paginate_queryset(enrollments_in_course)
retlist = []
for enrollment in paged_enrollments:
enrollment.user.enrollment_mode = enrollment.mode
retlist.append(enrollment.user)
return retlist
return paged_enrollments
def _serialize_user_grade(self, user, course_key, course_grade):
"""

View File

@@ -1,21 +1,35 @@
""" API v0 views. """
import json
import logging
from collections import defaultdict
from contextlib import contextmanager
from typing import List
from django.core.exceptions import ValidationError # lint-amnesty, pylint: disable=wrong-import-order
from django.db.models import Q
from edx_rest_framework_extensions import permissions
from edx_rest_framework_extensions.auth.bearer.authentication import BearerAuthentication
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from rest_framework.authentication import SessionAuthentication
from rest_framework.generics import ListAPIView
from rest_framework.response import Response
from common.djangoapps.student.models.course_enrollment import CourseEnrollment
from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import get_course
from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule
from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades
from lms.djangoapps.grades.rest_api.serializers import GradingPolicySerializer
from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination, GradeViewMixin
from openedx.core.djangoapps.enrollments.forms import CourseEnrollmentsApiListForm
from openedx.core.djangoapps.enrollments.views import EnrollmentUserThrottle
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import PaginatedAPIView, get_course_key, verify_course_exists
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
@@ -207,3 +221,318 @@ class CourseGradingPolicy(GradeViewMixin, ListAPIView):
def get(self, request, course_id, *args, **kwargs): # pylint: disable=arguments-differ
course = self._get_course(request, course_id)
return Response(GradingPolicySerializer(course.raw_grader, many=True).data)
class SectionGradesBreakdown(GradeViewMixin, PaginatedAPIView):
""" Section grades breakdown gives out the overall grade for a user in a course
accompanied by grades for each section of the course for the user.
"""
authentication_classes = (
JwtAuthentication,
BearerAuthentication,
SessionAuthentication,
)
permission_classes = (permissions.IsStaff,)
pagination_class = CourseEnrollmentPagination
def get(self, request): # pylint: disable=arguments-differ
"""
**Use Cases**
Get a list of all grades for all sections, optionally filtered by a course ID or list of usernames.
**Example Requests**
GET /api/grades/v1/section_grades_breakdown
GET /api/grades/v1/section_grades_breakdown?course_id={course_id}
GET /api/grades/v1/section_grades_breakdown?username={username},{username},{username}
GET /api/grades/v1/section_grades_breakdown?course_id={course_id}&username={username}
**Query Parameters for GET**
* course_id: Filters the result to course grade status for the course corresponding to the
given course ID. The value must be URL encoded. Optional.
* username: List of comma-separated usernames. Filters the result to the course grade status
of the given users. Optional.
* page_size: Number of results to return per page. Optional.
**Response Values**
If the request for information about the course grade status is successful, an HTTP 200 "OK" response
is returned.
The HTTP 200 response has the following values.
* results: A list of the course grading status matching the request.
* course_id: Course ID of the course in the course grading status.
* user: Username of the user in the course enrollment.
* passed: Boolean flag for user passing the course.
* current_grade: An integer representing the current grade of the course.
* section_breakdown: A summary of each course section's grade.
A dictionary in the section_breakdown list has the following keys:
* percent: A float percentage for the section.
* label: A short string identifying the section. Preferably fixed-length. E.g. "HW 3".
* detail: A string explanation of the score. E.g. "Homework 1 - Ohms Law - 83% (5/6)"
* category: A string identifying the category.
* prominent: A boolean value indicating that this section should be displayed as more prominent
than other items.
* next: The URL to the next page of results, or null if this is the
last page.
* previous: The URL to the next page of results, or null if this
is the first page.
If the user is not logged in, a 401 error is returned.
If the user is not global staff, a 403 error is returned.
If the specified course_id is not valid or any of the specified usernames
are not valid, a 400 error is returned.
If the specified course_id does not correspond to a valid course or if all the specified
usernames do not correspond to valid users, an HTTP 200 "OK" response is returned with an
empty 'results' field.
"""
course_grading_status = []
username_filter = []
form = CourseEnrollmentsApiListForm(self.request.query_params)
if not form.is_valid():
raise ValidationError(form.errors)
usernames = form.cleaned_data.get('username')
course_id = form.cleaned_data.get('course_id')
if usernames:
username_filter = [Q(user__username__in=usernames)]
course_enrollments = self._paginate_course_enrollment(course_id, course_enrollment_filter=username_filter)
enrolled_course_user_map = SectionGradesBreakdown._get_enrolled_course_user_map(course_enrollments)
for course_key, users in enrolled_course_user_map.items():
with bulk_course_grade_context(course_key, users):
for user, course_grade, exc in CourseGradeFactory().iter(users, course_key=course_key):
if not exc:
course_grading_status.append(
SectionGradesBreakdown._serialize_section_grades(user, course_key, course_grade)
)
return self.get_paginated_response(course_grading_status)
@staticmethod
def _get_enrolled_course_user_map(enrollments):
""" Returns a map of courses with all the users enrolled in them.
"""
enrolled_course_user_map = defaultdict(list)
for enrollment in enrollments:
enrolled_course_user_map[enrollment.course_id].append(enrollment.user)
return enrolled_course_user_map
@staticmethod
def _serialize_section_grades(user, course_key, course_grade):
"""
Convert the extracted information into a serialized structure.
Returns a dictionary with the following information about the course & course grade.
* course_id: Course id of the given course.
* username: Username of the user on the platform.
* passed: If the user passed the course or not.
* current_grade: An integer representing the current grade of the course.
* section_breakdown: A summary of each course section's grade.
"""
summary = []
for section in course_grade.summary.get('section_breakdown'):
summary.append(section)
course_grading_status = {
'course_id': str(course_key),
'username': user.username,
'passed': course_grade.passed,
'current_grade': int(course_grade.percent * 100),
'section_breakdown': summary,
}
return course_grading_status
@can_disable_rate_limit
class SubmissionHistoryView(GradeViewMixin, PaginatedAPIView):
"""
Submission history corresponding to ProblemBlocks present in the course.
"""
authentication_classes = (
JwtAuthentication,
BearerAuthentication,
SessionAuthentication,
)
permission_classes = (permissions.IsStaff,)
throttle_classes = (EnrollmentUserThrottle,)
pagination_class = CourseEnrollmentPagination
def get(self, request, course_id=None):
"""
Get submission history details. This submission history is related to only
ProblemBlock and it doesn't support LibraryContentBlock or ContentLibraries
as of now.
**Usecases**:
Users with GlobalStaff status can retrieve everyone's submission history.
**Example Requests**:
GET /api/grades/v1/submission_history/{course_id}
GET /api/grades/v1/submission_history/{course_id}/?username={username}
**Query Parameters for GET**
* course_id: Course id to retrieve submission history.
* username: Single username for which this view will retrieve the submission history details.
**Response Values**:
If there's an error while getting the submission history an empty response will
be returned.
The submission history response has the following attributes:
* Results: A list of submission history:
* course_id: Course id
* course_name: Course name
* user: Username
* problems: List of problems
* location: problem location
* name: problem's display name
* submission_history: List of submission history
* state: State of submission.
* grade: Grade.
* max_grade: Maximum possible grade.
* data: problem's data.
"""
data = []
username_filter = []
username = request.GET.get('username')
try:
course_id = get_course_key(request, course_id)
except InvalidKeyError:
raise self.api_error( # lint-amnesty, pylint: disable=raise-missing-from
status_code=status.HTTP_400_BAD_REQUEST,
developer_message='The provided course key cannot be parsed.',
error_code='invalid_course_key'
)
if username:
username_filter = [Q(user__username=username)]
course_enrollments = self._paginate_course_enrollment(course_id, course_enrollment_filter=username_filter)
course_xblock_structure = SubmissionHistoryView._generate_course_structure(course_enrollments)
for course_key, course_info in course_xblock_structure.items():
course_data = SubmissionHistoryView._get_course_data(
course_key,
course_info.get('course_enrollments'),
course_info.get('course'),
course_info.get('blocks')
)
data.extend(course_data)
return self.get_paginated_response(data)
@staticmethod
def _generate_course_structure(enrollments):
""" Generate a map of course to course enrollment and problem
xblocks for each of the course.
"""
course_enrollment_id_map = defaultdict(list)
course_xblock_structure = {}
for course_enrollment in enrollments:
course_enrollment_id_map[str(course_enrollment.course_id)].append(course_enrollment)
for course_key, course_enrollments in course_enrollment_id_map.items():
course_id = CourseKey.from_string(course_key)
course = get_course(course_id, depth=4)
course_xblock_structure[course_key] = {
'course_enrollments': course_enrollments,
'blocks': SubmissionHistoryView.get_problem_blocks(course),
'course': course
}
return course_xblock_structure
@staticmethod
def get_problem_blocks(course):
""" Get a list of problem xblock for the course.
This doesn't support LibraryContentBlock or ContentLibraries
as of now
"""
blocks = []
for section in course.get_children():
for subsection in section.get_children():
for vertical in subsection.get_children():
for block in vertical.get_children():
if block.category == 'problem' and getattr(block, 'has_score', False):
blocks.append(block)
return blocks
@staticmethod
def _get_course_data(course_key: str, course_enrollments: List[CourseEnrollment], course, blocks):
"""
Extracts the fields needed from course enrollments and course block.
This function maps the ProblemBlock data of the course to it's enrollment.
Params:
--------
course: course
block: XBlock to analyze.
"""
course_grouped_data = []
for course_enrollment in course_enrollments:
course_data = {
'course_id': course_key,
'course_name': course.display_name_with_default,
'user': course_enrollment.user.username,
'problems': []
}
for block in blocks:
problem_data = SubmissionHistoryView._get_problem_data(course_enrollment, block)
if problem_data["submission_history"]:
course_data['problems'].append(problem_data)
course_grouped_data.append(course_data)
return course_grouped_data
@staticmethod
def _get_problem_data(course_enrollment: CourseEnrollment, block):
"""
Get problem data from a course enrollment.
Args:
-----
block: XBlock to analyze.
"""
problem_data = {
'location': str(block.scope_ids.usage_id),
'name': block.display_name,
'submission_history': [],
'data': block.data
}
csm = StudentModule.objects.filter(
module_state_key=block.location,
student=course_enrollment.user,
course_id=course_enrollment.course_id
)
scores = BaseStudentModuleHistory.get_history(csm)
for score in scores:
state = score.state
if state is not None:
state = json.loads(state)
history_data = {
'state': state,
'grade': score.grade,
'max_grade': score.max_grade
}
problem_data['submission_history'].append(history_data)
return problem_data

View File

@@ -11,3 +11,6 @@ class CourseEnrollmentsApiListPagination(CursorPagination):
Paginator for the Course enrollments list API.
"""
page_size = 100
page_size_query_param = 'page_size'
max_page_size = 100
page_query_param = 'page'