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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user