feat: Added grades API for submission history and section grades breakdown.
This feature helps to add submission history for each ProblemBlock in the course. It also adds API for section grades breakdown, that gives information about grades scored in each section of the course. Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
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
|
||||
|
||||
Reference in New Issue
Block a user