876 lines
37 KiB
Python
876 lines
37 KiB
Python
""" API v0 views. """
|
|
import logging
|
|
from collections import defaultdict, namedtuple
|
|
from contextlib import contextmanager
|
|
from functools import wraps
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.urls import reverse
|
|
from rest_framework import status
|
|
from rest_framework.exceptions import AuthenticationFailed
|
|
from rest_framework.pagination import CursorPagination
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
from six import text_type
|
|
from util.date_utils import to_timestamp
|
|
|
|
from courseware.courses import get_course_with_access
|
|
from edx_rest_framework_extensions import permissions
|
|
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
|
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
|
|
from lms.djangoapps.grades.api.serializers import StudentGradebookEntrySerializer
|
|
from lms.djangoapps.grades.config.waffle import waffle_flags, WRITABLE_GRADEBOOK
|
|
from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum
|
|
from lms.djangoapps.grades.course_data import CourseData
|
|
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
|
|
from lms.djangoapps.grades.events import SUBSECTION_GRADE_CALCULATED, subsection_grade_calculated
|
|
from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride
|
|
from lms.djangoapps.grades.subsection_grade import CreateSubsectionGrade
|
|
from lms.djangoapps.grades.tasks import recalculate_subsection_grade_v3, are_grades_frozen
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
from openedx.core.djangoapps.course_groups import cohorts
|
|
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
|
|
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
|
|
from student.models import CourseEnrollment
|
|
from track.event_transaction_utils import (
|
|
create_new_event_transaction_id,
|
|
get_event_transaction_id,
|
|
get_event_transaction_type,
|
|
set_event_transaction_type
|
|
)
|
|
from xmodule.util.misc import get_default_short_labeler
|
|
|
|
log = logging.getLogger(__name__)
|
|
USER_MODEL = get_user_model()
|
|
|
|
|
|
def get_course_key(request, course_id=None):
|
|
if not course_id:
|
|
return CourseKey.from_string(request.GET.get('course_id'))
|
|
return CourseKey.from_string(course_id)
|
|
|
|
|
|
def verify_course_exists(view_func):
|
|
"""
|
|
A decorator to wrap a view function that takes `course_key` as a parameter.
|
|
|
|
Raises:
|
|
An API error if the `course_key` is invalid, or if no `CourseOverview` exists for the given key.
|
|
"""
|
|
@wraps(view_func)
|
|
def wrapped_function(self, request, **kwargs):
|
|
"""
|
|
Wraps the given view_function.
|
|
"""
|
|
try:
|
|
course_key = get_course_key(request, kwargs.get('course_id'))
|
|
except InvalidKeyError:
|
|
raise self.api_error(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
developer_message='The provided course key cannot be parsed.',
|
|
error_code='invalid_course_key'
|
|
)
|
|
|
|
if not CourseOverview.get_from_id_if_exists(course_key):
|
|
raise self.api_error(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
developer_message="Requested grade for unknown course {course}".format(course=text_type(course_key)),
|
|
error_code='course_does_not_exist'
|
|
)
|
|
|
|
return view_func(self, request, **kwargs)
|
|
return wrapped_function
|
|
|
|
|
|
def verify_writable_gradebook_enabled(view_func):
|
|
"""
|
|
A decorator to wrap a view function that takes `course_key` as a parameter.
|
|
|
|
Raises:
|
|
A 403 API error if the writable gradebook feature is not enabled for the given course.
|
|
"""
|
|
@wraps(view_func)
|
|
def wrapped_function(self, request, **kwargs):
|
|
"""
|
|
Wraps the given view function.
|
|
"""
|
|
course_key = get_course_key(request, kwargs.get('course_id'))
|
|
if not waffle_flags()[WRITABLE_GRADEBOOK].is_enabled(course_key):
|
|
raise self.api_error(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
developer_message='The writable gradebook feature is not enabled for this course.',
|
|
error_code='feature_not_enabled'
|
|
)
|
|
return view_func(self, request, **kwargs)
|
|
return wrapped_function
|
|
|
|
|
|
class CourseEnrollmentPagination(CursorPagination):
|
|
"""
|
|
Paginates over CourseEnrollment objects.
|
|
"""
|
|
page_size = 50
|
|
ordering = 'id'
|
|
|
|
|
|
class PaginatedAPIView(APIView):
|
|
"""
|
|
An `APIView` class enhanced with the pagination methods of `GenericAPIView`.
|
|
"""
|
|
# pylint: disable=attribute-defined-outside-init
|
|
@property
|
|
def paginator(self):
|
|
"""
|
|
The paginator instance associated with the view, or `None`.
|
|
"""
|
|
if not hasattr(self, '_paginator'):
|
|
if self.pagination_class is None:
|
|
self._paginator = None
|
|
else:
|
|
self._paginator = self.pagination_class()
|
|
return self._paginator
|
|
|
|
def paginate_queryset(self, queryset):
|
|
"""
|
|
Return a single page of results, or `None` if pagination is disabled.
|
|
"""
|
|
if self.paginator is None:
|
|
return None
|
|
return self.paginator.paginate_queryset(queryset, self.request, view=self)
|
|
|
|
def get_paginated_response(self, data):
|
|
"""
|
|
Return a paginated style `Response` object for the given output data.
|
|
"""
|
|
assert self.paginator is not None
|
|
return self.paginator.get_paginated_response(data)
|
|
|
|
|
|
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,
|
|
then the request's `username` parameter, or the current `request.user` if no `username` was provided.
|
|
|
|
Args:
|
|
request (Request): django request object to check for username or request.user object
|
|
course_key (CourseLocator): The course to retrieve user grades for.
|
|
user_id (int): Optional user id to fetch the user object for.
|
|
|
|
Returns:
|
|
A USER_MODEL object.
|
|
|
|
Raises:
|
|
USER_MODEL.DoesNotExist if no such user exists.
|
|
CourseEnrollment.DoesNotExist if the user is not enrolled in the given course.
|
|
"""
|
|
# May raise USER_MODEL.DoesNotExist if no user matching the given query exists.
|
|
if user_id:
|
|
grade_user = USER_MODEL.objects.get(id=user_id)
|
|
elif 'username' in request.GET:
|
|
grade_user = USER_MODEL.objects.get(username=request.GET.get('username'))
|
|
else:
|
|
grade_user = request.user
|
|
|
|
# May raise CourseEnrollment.DoesNotExist if no enrollment exists for this user/course.
|
|
_ = CourseEnrollment.objects.get(user=grade_user, course_id=course_key)
|
|
|
|
return grade_user
|
|
|
|
@contextmanager
|
|
def _get_user_or_raise(self, request, course_key):
|
|
"""
|
|
Raises an API error if the username specified by the request does not exist, or if the
|
|
user is not enrolled in the given course.
|
|
Args:
|
|
request (Request): django request object to check for username or request.user object
|
|
course_key (CourseLocator): The course to retrieve user grades for.
|
|
|
|
Yields:
|
|
A USER_MODEL object.
|
|
"""
|
|
try:
|
|
yield self._get_single_user(request, course_key)
|
|
except USER_MODEL.DoesNotExist:
|
|
raise self.api_error(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
developer_message='The user matching the requested username does not exist.',
|
|
error_code='user_does_not_exist'
|
|
)
|
|
except CourseEnrollment.DoesNotExist:
|
|
raise self.api_error(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
developer_message='The user matching the requested username is not enrolled in this course',
|
|
error_code='user_not_enrolled'
|
|
)
|
|
|
|
def _get_single_user_grade(self, grade_user, course_key):
|
|
"""
|
|
Returns a grade response for the user object corresponding to the request's 'username' parameter,
|
|
or the current request.user if no 'username' was provided.
|
|
Args:
|
|
request (Request): django request object to check for username or request.user object
|
|
course_key (CourseLocator): The course to retrieve user grades for.
|
|
|
|
Returns:
|
|
A serializable list of grade responses
|
|
"""
|
|
course_grade = CourseGradeFactory().read(grade_user, course_key=course_key)
|
|
return Response([self._serialize_user_grade(grade_user, course_key, course_grade)])
|
|
|
|
def _iter_user_grades(self, course_key, course_enrollment_filter=None, related_models=None):
|
|
"""
|
|
Args:
|
|
course_key (CourseLocator): The course to retrieve grades for.
|
|
course_enrollment_filter: Optional dictionary of keyword arguments to pass
|
|
to `CourseEnrollment.filter()`.
|
|
related_models: Optional list of related models to join to the CourseEnrollment table.
|
|
|
|
Returns:
|
|
An iterator of CourseGrade objects for users enrolled in the given course.
|
|
"""
|
|
filter_kwargs = {
|
|
'course_id': course_key,
|
|
'is_active': True,
|
|
}
|
|
filter_kwargs.update(course_enrollment_filter or {})
|
|
enrollments_in_course = CourseEnrollment.objects.filter(**filter_kwargs)
|
|
if related_models:
|
|
enrollments_in_course = enrollments_in_course.select_related(*related_models)
|
|
|
|
paged_enrollments = self.paginate_queryset(enrollments_in_course)
|
|
users = (enrollment.user for enrollment in paged_enrollments)
|
|
grades = CourseGradeFactory().iter(users, course_key=course_key)
|
|
|
|
for user, course_grade, exc in grades:
|
|
yield user, course_grade, exc
|
|
|
|
def _serialize_user_grade(self, user, course_key, course_grade):
|
|
"""
|
|
Serialize a single grade to dict to use in Responses
|
|
"""
|
|
return {
|
|
'username': user.username,
|
|
'email': user.email,
|
|
'course_id': str(course_key),
|
|
'passed': course_grade.passed,
|
|
'percent': course_grade.percent,
|
|
'letter_grade': course_grade.letter_grade,
|
|
}
|
|
|
|
def perform_authentication(self, request):
|
|
"""
|
|
Ensures that the user is authenticated (e.g. not an AnonymousUser).
|
|
"""
|
|
super(GradeViewMixin, self).perform_authentication(request)
|
|
if request.user.is_anonymous:
|
|
raise AuthenticationFailed
|
|
|
|
|
|
class SubsectionLabelFinder(object):
|
|
"""
|
|
Finds the grader label (a short string identifying the section) of a graded section.
|
|
"""
|
|
def __init__(self, course_grade):
|
|
"""
|
|
Args:
|
|
course_grade: A CourseGrade object.
|
|
"""
|
|
self.section_summaries = [section for section in course_grade.summary.get('section_breakdown', [])]
|
|
|
|
def _get_subsection_summary(self, display_name):
|
|
"""
|
|
Given a subsection's display_name and a breakdown of section grades from CourseGrade.summary,
|
|
return the summary data corresponding to the subsection with this display_name.
|
|
"""
|
|
for index, section in enumerate(self.section_summaries):
|
|
if display_name.lower() in section['detail'].lower():
|
|
return index, section
|
|
return -1, None
|
|
|
|
def get_label(self, display_name):
|
|
"""
|
|
Returns the grader short label corresponding to the display_name, or None
|
|
if no match was found.
|
|
"""
|
|
section_index, summary = self._get_subsection_summary(display_name)
|
|
if summary:
|
|
# It's possible that two subsections/assignments would have the same display name.
|
|
# since the grade summary and chapter_grades data are presumably in a sorted order,
|
|
# we'll take the first matching section summary and remove it from the pool of
|
|
# section_summaries.
|
|
self.section_summaries.pop(section_index)
|
|
return summary['label']
|
|
|
|
|
|
class CourseGradesView(GradeViewMixin, PaginatedAPIView):
|
|
"""
|
|
**Use Case**
|
|
* Get course grades of all users who are enrolled in a course.
|
|
The currently logged-in user may request all enrolled user's grades information
|
|
if they are allowed.
|
|
**Example Request**
|
|
GET /api/grades/v1/courses/{course_id}/ - Get grades for all users in course
|
|
GET /api/grades/v1/courses/{course_id}/?username={username} - Get grades for specific user in course
|
|
GET /api/grades/v1/courses/?course_id={course_id} - Get grades for all users in course
|
|
GET /api/grades/v1/courses/?course_id={course_id}&username={username}- Get grades for specific user in course
|
|
**GET Parameters**
|
|
A GET request may include the following parameters.
|
|
* course_id: (required) A string representation of a Course ID.
|
|
* username: (optional) A string representation of a user's username.
|
|
**GET Response Values**
|
|
If the request for information about the course grade
|
|
is successful, an HTTP 200 "OK" response is returned.
|
|
The HTTP 200 response has the following values.
|
|
* username: A string representation of a user's username passed in the request.
|
|
* email: A string representation of a user's email.
|
|
* course_id: A string representation of a Course ID.
|
|
* passed: Boolean representing whether the course has been
|
|
passed according to the course's grading policy.
|
|
* percent: A float representing the overall grade for the course
|
|
* letter_grade: A letter grade as defined in grading policy (e.g. 'A' 'B' 'C' for 6.002x) or None
|
|
**Example GET Response**
|
|
[{
|
|
"username": "bob",
|
|
"email": "bob@example.com",
|
|
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
|
"passed": false,
|
|
"percent": 0.03,
|
|
"letter_grade": null,
|
|
},
|
|
{
|
|
"username": "fred",
|
|
"email": "fred@example.com",
|
|
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
|
"passed": true,
|
|
"percent": 0.83,
|
|
"letter_grade": "B",
|
|
},
|
|
{
|
|
"username": "kate",
|
|
"email": "kate@example.com",
|
|
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
|
"passed": false,
|
|
"percent": 0.19,
|
|
"letter_grade": null,
|
|
}]
|
|
"""
|
|
authentication_classes = (
|
|
JwtAuthentication,
|
|
OAuth2AuthenticationAllowInactiveUser,
|
|
SessionAuthenticationAllowInactiveUser,
|
|
)
|
|
|
|
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
|
|
|
|
pagination_class = CourseEnrollmentPagination
|
|
|
|
required_scopes = ['grades:read']
|
|
|
|
@verify_course_exists
|
|
def get(self, request, course_id=None):
|
|
"""
|
|
Gets a course progress status.
|
|
Args:
|
|
request (Request): Django request object.
|
|
course_id (string): URI element specifying the course location.
|
|
Can also be passed as a GET parameter instead.
|
|
Return:
|
|
A JSON serialized representation of the requesting user's current grade status.
|
|
"""
|
|
username = request.GET.get('username')
|
|
|
|
course_key = get_course_key(request, course_id)
|
|
|
|
if username:
|
|
# If there is a username passed, get grade for a single user
|
|
with self._get_user_or_raise(request, course_key) as grade_user:
|
|
return self._get_single_user_grade(grade_user, course_key)
|
|
else:
|
|
# If no username passed, get paginated list of grades for all users in course
|
|
return self._get_user_grades(course_key)
|
|
|
|
def _get_user_grades(self, course_key):
|
|
"""
|
|
Get paginated grades for users in a course.
|
|
Args:
|
|
course_key (CourseLocator): The course to retrieve user grades for.
|
|
|
|
Returns:
|
|
A serializable list of grade responses
|
|
"""
|
|
user_grades = []
|
|
for user, course_grade, exc in self._iter_user_grades(course_key):
|
|
if not exc:
|
|
user_grades.append(self._serialize_user_grade(user, course_key, course_grade))
|
|
|
|
return self.get_paginated_response(user_grades)
|
|
|
|
|
|
class GradebookView(GradeViewMixin, PaginatedAPIView):
|
|
"""
|
|
**Use Case**
|
|
* Get course gradebook entries of a single user in a course,
|
|
or of all users who are actively enrolled in a course. The currently logged-in user may request
|
|
all enrolled user's grades information if they are allowed.
|
|
**Example Request**
|
|
GET /api/grades/v1/gradebook/{course_id}/ - Get gradebook entries for all users in course
|
|
GET /api/grades/v1/gradebook/{course_id}/?username={username} - Get grades for specific user in course
|
|
GET /api/grades/v1/gradebook/{course_id}/?username_contains={username_contains}
|
|
GET /api/grades/v1/gradebook/{course_id}/?cohort_id={cohort_id}
|
|
GET /api/grades/v1/gradebook/{course_id}/?enrollment_mode={enrollment_mode}
|
|
**GET Parameters**
|
|
A GET request may include the following query parameters.
|
|
* username: (optional) A string representation of a user's username.
|
|
* username_contains: (optional) A substring against which a case-insensitive substring filter will be performed
|
|
on the USER_MODEL.username field.
|
|
* cohort_id: (optional) The id of a cohort in this course. If present, will return grades
|
|
only for course enrollees who belong to that cohort.
|
|
* enrollment_mode: (optional) The slug of an enrollment mode (e.g. "verified"). If present, will return grades
|
|
only for course enrollees with the given enrollment mode.
|
|
**GET Response Values**
|
|
If the request for gradebook data is successful,
|
|
an HTTP 200 "OK" response is returned.
|
|
The HTTP 200 response for a single has the following values:
|
|
* course_id: A string representation of a Course ID.
|
|
* email: A string representation of a user's email.
|
|
* user_id: The user's integer id.
|
|
* username: A string representation of a user's username passed in the request.
|
|
* full_name: A string representation of the user's full name.
|
|
* passed: Boolean representing whether the course has been
|
|
passed according to the course's grading policy.
|
|
* percent: A float representing the overall grade for the course
|
|
* letter_grade: A letter grade as defined in grading policy (e.g. 'A' 'B' 'C' for 6.002x) or None
|
|
* progress_page_url: A link to the user's progress page.
|
|
* section_breakdown: A list of subsection grade details, as specified below.
|
|
* aggregates: A dict containing earned and possible scores (floats), broken down by subsection type
|
|
(e.g. "Exam", "Homework", "Lab").
|
|
|
|
A response for all user's grades in the course is paginated, and contains "count", "next" and "previous"
|
|
keys, along with the actual data contained in a "results" list.
|
|
|
|
An HTTP 404 may be returned for the following reasons:
|
|
* The requested course_key is invalid.
|
|
* No course corresponding to the requested key exists.
|
|
* No user corresponding to the requested username exists.
|
|
* The requested user is not enrolled in the requested course.
|
|
|
|
An HTTP 403 may be returned if the `writable_gradebook` feature is not
|
|
enabled for this course.
|
|
**Example GET Response**
|
|
{
|
|
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
|
"email": "staff@example.com",
|
|
"user_id": 9,
|
|
"username": "staff",
|
|
"full_name": "",
|
|
"passed": false,
|
|
"percent": 0.36,
|
|
"letter_grade": null,
|
|
"progress_page_url": "/courses/course-v1:edX+DemoX+Demo_Course/progress/9/",
|
|
"section_breakdown": [
|
|
{
|
|
"are_grades_published": true,
|
|
"auto_grade": false,
|
|
"category": null,
|
|
"chapter_name": "Introduction",
|
|
"comment": "",
|
|
"detail": "",
|
|
"displayed_value": "0.00",
|
|
"is_graded": false,
|
|
"grade_description": "(0.00/0.00)",
|
|
"is_ag": false,
|
|
"is_average": false,
|
|
"is_manually_graded": false,
|
|
"label": null,
|
|
"letter_grade": null,
|
|
"module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
|
"percent": 0.0,
|
|
"score_earned": 0.0,
|
|
"score_possible": 0.0,
|
|
"section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@abcdefgh123",
|
|
"subsection_name": "Demo Course Overview"
|
|
},
|
|
],
|
|
"aggregates": {
|
|
"Exam": {
|
|
"score_possible": 6.0,
|
|
"score_earned": 0.0
|
|
},
|
|
"Homework": {
|
|
"score_possible": 16.0,
|
|
"score_earned": 10.0
|
|
}
|
|
}
|
|
}
|
|
**Paginated GET response**
|
|
When requesting gradebook entries for all users, the response is paginated and contains the following values:
|
|
* count: The total number of user gradebook entries for this course.
|
|
* next: The URL containing the next page of data.
|
|
* previous: The URL containing the previous page of data.
|
|
* results: A list of user gradebook entries, structured as above.
|
|
|
|
Note: It's important that `GradeViewMixin` is the first inherited class here, so that
|
|
self.api_error returns error responses as expected.
|
|
"""
|
|
authentication_classes = (
|
|
JwtAuthentication,
|
|
OAuth2AuthenticationAllowInactiveUser,
|
|
SessionAuthenticationAllowInactiveUser,
|
|
)
|
|
|
|
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
|
|
|
|
pagination_class = CourseEnrollmentPagination
|
|
|
|
required_scopes = ['grades:read']
|
|
|
|
def _section_breakdown(self, course, course_grade):
|
|
"""
|
|
Given a course_grade, returns a list of grade data broken down by subsection
|
|
and a dictionary containing aggregate grade data by subsection format for the course.
|
|
|
|
Args:
|
|
course_grade: A CourseGrade object.
|
|
"""
|
|
breakdown = []
|
|
aggregates = defaultdict(lambda: defaultdict(float))
|
|
|
|
# TODO: https://openedx.atlassian.net/browse/EDUCATOR-3559
|
|
# Fields we may not need:
|
|
# ['are_grades_published', 'auto_grade', 'comment', 'detail', 'is_ag', 'is_average', 'is_manually_graded']
|
|
# Some fields should be renamed:
|
|
# 'displayed_value' should maybe be 'description_percent'
|
|
# 'grade_description' should be 'description_ratio'
|
|
|
|
label_finder = SubsectionLabelFinder(course_grade)
|
|
default_labeler = get_default_short_labeler(course)
|
|
|
|
for chapter_location, section_data in course_grade.chapter_grades.items():
|
|
for subsection_grade in section_data['sections']:
|
|
default_short_label = default_labeler(subsection_grade.format)
|
|
breakdown.append({
|
|
'are_grades_published': True,
|
|
'auto_grade': False,
|
|
'category': subsection_grade.format,
|
|
'chapter_name': section_data['display_name'],
|
|
'comment': '',
|
|
'detail': '',
|
|
'displayed_value': '{:.2f}'.format(subsection_grade.percent_graded),
|
|
'is_graded': subsection_grade.graded,
|
|
'grade_description': '({earned:.2f}/{possible:.2f})'.format(
|
|
earned=subsection_grade.graded_total.earned,
|
|
possible=subsection_grade.graded_total.possible,
|
|
),
|
|
'is_ag': False,
|
|
'is_average': False,
|
|
'is_manually_graded': False,
|
|
'label': label_finder.get_label(subsection_grade.display_name) or default_short_label,
|
|
'letter_grade': course_grade.letter_grade,
|
|
'module_id': text_type(subsection_grade.location),
|
|
'percent': subsection_grade.percent_graded,
|
|
'score_earned': subsection_grade.graded_total.earned,
|
|
'score_possible': subsection_grade.graded_total.possible,
|
|
'section_block_id': text_type(chapter_location),
|
|
'subsection_name': subsection_grade.display_name,
|
|
})
|
|
if subsection_grade.graded and subsection_grade.graded_total.possible > 0:
|
|
aggregates[subsection_grade.format]['score_earned'] += subsection_grade.graded_total.earned
|
|
aggregates[subsection_grade.format]['score_possible'] += subsection_grade.graded_total.possible
|
|
|
|
return breakdown, aggregates
|
|
|
|
def _gradebook_entry(self, user, course, course_grade):
|
|
"""
|
|
Returns a dictionary of course- and subsection-level grade data for
|
|
a given user in a given course.
|
|
|
|
Args:
|
|
user: A User object.
|
|
course: A Course Descriptor object.
|
|
course_grade: A CourseGrade object.
|
|
"""
|
|
user_entry = self._serialize_user_grade(user, course.id, course_grade)
|
|
breakdown, aggregates = self._section_breakdown(course, course_grade)
|
|
|
|
user_entry['section_breakdown'] = breakdown
|
|
user_entry['aggregates'] = aggregates
|
|
user_entry['progress_page_url'] = reverse(
|
|
'student_progress',
|
|
kwargs=dict(course_id=text_type(course.id), student_id=user.id)
|
|
)
|
|
user_entry['user_id'] = user.id
|
|
user_entry['full_name'] = user.get_full_name()
|
|
|
|
return user_entry
|
|
|
|
@verify_course_exists
|
|
@verify_writable_gradebook_enabled
|
|
def get(self, request, course_id):
|
|
"""
|
|
Returns a gradebook entry/entries (i.e. both course and subsection-level grade data)
|
|
for all users enrolled in a course, or a single user enrolled in a course
|
|
if a `username` parameter is provided.
|
|
|
|
Args:
|
|
request: A Django request object.
|
|
course_id: A string representation of a CourseKey object.
|
|
"""
|
|
course_key = get_course_key(request, course_id)
|
|
course = get_course_with_access(request.user, 'staff', course_key, depth=None)
|
|
|
|
if request.GET.get('username'):
|
|
with self._get_user_or_raise(request, course_key) as grade_user:
|
|
course_grade = CourseGradeFactory().read(grade_user, course)
|
|
|
|
entry = self._gradebook_entry(grade_user, course, course_grade)
|
|
serializer = StudentGradebookEntrySerializer(entry)
|
|
return Response(serializer.data)
|
|
else:
|
|
filter_kwargs = {}
|
|
related_models = []
|
|
if request.GET.get('username_contains'):
|
|
filter_kwargs['user__username__icontains'] = request.GET.get('username_contains')
|
|
related_models.append('user')
|
|
if request.GET.get('cohort_id'):
|
|
cohort = cohorts.get_cohort_by_id(course_key, request.GET.get('cohort_id'))
|
|
if cohort:
|
|
filter_kwargs['user__in'] = cohort.users.all()
|
|
else:
|
|
filter_kwargs['user__in'] = []
|
|
if request.GET.get('enrollment_mode'):
|
|
filter_kwargs['mode'] = request.GET.get('enrollment_mode')
|
|
|
|
user_grades = self._iter_user_grades(course_key, filter_kwargs, related_models)
|
|
|
|
entries = []
|
|
for user, course_grade, exc in user_grades:
|
|
if not exc:
|
|
entries.append(self._gradebook_entry(user, course, course_grade))
|
|
serializer = StudentGradebookEntrySerializer(entries, many=True)
|
|
return self.get_paginated_response(serializer.data)
|
|
|
|
|
|
GradebookUpdateResponseItem = namedtuple('GradebookUpdateResponseItem', ['user_id', 'usage_id', 'success', 'reason'])
|
|
|
|
|
|
class GradebookBulkUpdateView(GradeViewMixin, PaginatedAPIView):
|
|
"""
|
|
**Use Case**
|
|
Creates `PersistentSubsectionGradeOverride` objects for multiple (user_id, usage_id)
|
|
pairs in a given course, and invokes a Django signal to update subsection grades in
|
|
an asynchronous celery task.
|
|
|
|
**Example Request**
|
|
POST /api/grades/v1/gradebook/{course_id}/bulk-update
|
|
|
|
**POST Parameters**
|
|
This endpoint does not accept any URL parameters.
|
|
|
|
**Example POST Data**
|
|
[
|
|
{
|
|
"user_id": 9,
|
|
"usage_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
|
|
"grade": {
|
|
"earned_all_override": 11,
|
|
"possible_all_override": 11,
|
|
"earned_graded_override": 11,
|
|
"possible_graded_override": 11
|
|
}
|
|
},
|
|
{
|
|
"user_id": 9,
|
|
"usage_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@advanced_questions",
|
|
"grade": {
|
|
"earned_all_override": 10,
|
|
"possible_all_override": 15,
|
|
"earned_graded_override": 9,
|
|
"possible_graded_override": 12
|
|
}
|
|
}
|
|
]
|
|
|
|
**POST Response Values**
|
|
An HTTP 202 may be returned if a grade override was created for each of the requested (user_id, usage_id)
|
|
pairs in the request data.
|
|
An HTTP 403 may be returned if the `writable_gradebook` feature is not
|
|
enabled for this course.
|
|
An HTTP 404 may be returned for the following reasons:
|
|
* The requested course_key is invalid.
|
|
* No course corresponding to the requested key exists.
|
|
* The requesting user is not enrolled in the requested course.
|
|
An HTTP 422 may be returned if any of the requested (user_id, usage_id) pairs
|
|
did not have a grade override created due to some exception. A `reason` detailing the exception
|
|
is provided with each response item.
|
|
|
|
**Example successful POST Response**
|
|
[
|
|
{
|
|
"user_id": 9,
|
|
"usage_id": "some-requested-usage-id",
|
|
"success": true,
|
|
"reason": null
|
|
},
|
|
{
|
|
"user_id": 9,
|
|
"usage_id": "an-invalid-usage-id",
|
|
"success": false,
|
|
"reason": "<class 'opaque_keys.edx.locator.BlockUsageLocator'>: not-a-valid-usage-key"
|
|
},
|
|
{
|
|
"user_id": 9,
|
|
"usage_id": "a-valid-usage-key-that-doesn't-exist",
|
|
"success": false,
|
|
"reason": "a-valid-usage-key-that-doesn't-exist does not exist in this course"
|
|
},
|
|
{
|
|
"user_id": 1234-I-DO-NOT-EXIST,
|
|
"usage_id": "a-valid-usage-key",
|
|
"success": false,
|
|
"reason": "User matching query does not exist."
|
|
}
|
|
]
|
|
"""
|
|
authentication_classes = (
|
|
JwtAuthentication,
|
|
OAuth2AuthenticationAllowInactiveUser,
|
|
SessionAuthenticationAllowInactiveUser,
|
|
)
|
|
|
|
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
|
|
|
|
required_scopes = ['grades:write']
|
|
|
|
@verify_course_exists
|
|
@verify_writable_gradebook_enabled
|
|
def post(self, request, course_id):
|
|
"""
|
|
Creates or updates `PersistentSubsectionGradeOverrides` for the (user_id, usage_key)
|
|
specified in the request data. The `SUBSECTION_OVERRIDE_CHANGED` signal is invoked
|
|
after the grade override is created, which triggers a celery task to update the
|
|
course and subsection grades for the specified user.
|
|
"""
|
|
course_key = get_course_key(request, course_id)
|
|
if are_grades_frozen(course_key):
|
|
raise self.api_error(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
developer_message='Grades are frozen for this course.',
|
|
error_code='grades_frozen'
|
|
)
|
|
|
|
course = get_course_with_access(request.user, 'staff', course_key, depth=None)
|
|
|
|
result = []
|
|
|
|
for user_data in request.data:
|
|
requested_user_id = user_data['user_id']
|
|
requested_usage_id = user_data['usage_id']
|
|
try:
|
|
user = self._get_single_user(request, course_key, requested_user_id)
|
|
usage_key = UsageKey.from_string(requested_usage_id)
|
|
except (USER_MODEL.DoesNotExist, InvalidKeyError, CourseEnrollment.DoesNotExist) as exc:
|
|
result.append(GradebookUpdateResponseItem(
|
|
user_id=requested_user_id,
|
|
usage_id=requested_usage_id,
|
|
success=False,
|
|
reason=text_type(exc)
|
|
))
|
|
continue
|
|
|
|
try:
|
|
subsection_grade_model = PersistentSubsectionGrade.objects.get(
|
|
user_id=user.id,
|
|
course_id=course_key,
|
|
usage_key=usage_key
|
|
)
|
|
except PersistentSubsectionGrade.DoesNotExist:
|
|
subsection = course.get_child(usage_key)
|
|
if subsection:
|
|
subsection_grade_model = self._create_subsection_grade(user, course, subsection)
|
|
else:
|
|
result.append(GradebookUpdateResponseItem(
|
|
user_id=requested_user_id,
|
|
usage_id=requested_usage_id,
|
|
success=False,
|
|
reason='usage_key {} does not exist in this course.'.format(usage_key)
|
|
))
|
|
continue
|
|
|
|
if subsection_grade_model:
|
|
self._create_override(subsection_grade_model, **user_data['grade'])
|
|
result.append(GradebookUpdateResponseItem(
|
|
user_id=user.id,
|
|
usage_id=text_type(usage_key),
|
|
success=True,
|
|
reason=None
|
|
))
|
|
|
|
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
if all((item.success for item in result)):
|
|
status_code = status.HTTP_202_ACCEPTED
|
|
|
|
return Response(
|
|
[item._asdict() for item in result],
|
|
status=status_code,
|
|
content_type='application/json'
|
|
)
|
|
|
|
def _create_subsection_grade(self, user, course, subsection):
|
|
course_data = CourseData(user, course=course)
|
|
subsection_grade = CreateSubsectionGrade(subsection, course_data.structure, {}, {})
|
|
return subsection_grade.update_or_create_model(user, force_update_subsections=True)
|
|
|
|
def _create_override(self, subsection_grade_model, **override_data):
|
|
"""
|
|
Helper method to create a `PersistentSubsectionGradeOverride` object
|
|
and send a `SUBSECTION_OVERRIDE_CHANGED` signal.
|
|
"""
|
|
override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(
|
|
grade=subsection_grade_model,
|
|
defaults=self._clean_override_data(override_data),
|
|
)
|
|
|
|
set_event_transaction_type(SUBSECTION_GRADE_CALCULATED)
|
|
create_new_event_transaction_id()
|
|
|
|
recalculate_subsection_grade_v3.apply(
|
|
kwargs=dict(
|
|
user_id=subsection_grade_model.user_id,
|
|
anonymous_user_id=None,
|
|
course_id=text_type(subsection_grade_model.course_id),
|
|
usage_id=text_type(subsection_grade_model.usage_key),
|
|
only_if_higher=False,
|
|
expected_modified_time=to_timestamp(override.modified),
|
|
score_deleted=False,
|
|
event_transaction_id=unicode(get_event_transaction_id()),
|
|
event_transaction_type=unicode(get_event_transaction_type()),
|
|
score_db_table=ScoreDatabaseTableEnum.overrides,
|
|
force_update_subsections=True,
|
|
)
|
|
)
|
|
# Emit events to let our tracking system to know we updated subsection grade
|
|
subsection_grade_calculated(subsection_grade_model)
|
|
|
|
def _clean_override_data(self, override_data):
|
|
"""
|
|
Helper method to strip any grade override field names that won't work
|
|
as defaults when calling PersistentSubsectionGradeOverride.update_or_create().
|
|
"""
|
|
allowed_fields = {
|
|
'earned_all_override',
|
|
'possible_all_override',
|
|
'earned_graded_override',
|
|
'possible_graded_override',
|
|
}
|
|
stripped_data = {}
|
|
for field in override_data.keys():
|
|
if field in allowed_fields:
|
|
stripped_data[field] = override_data[field]
|
|
return stripped_data
|