""" Defines an endpoint for gradebook data related to a course. """ import logging from collections import namedtuple from contextlib import contextmanager from functools import wraps from django.contrib.auth import get_user_model from django.core.cache import cache from django.db.models import Case, Exists, F, OuterRef, Q, When from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.views import APIView from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.student.models import CourseEnrollment, CourseAccessRole from common.djangoapps.student.roles import BulkRoleCache from common.djangoapps.track.event_transaction_utils import ( create_new_event_transaction_id, get_event_transaction_id, get_event_transaction_type, set_event_transaction_type ) from common.djangoapps.util.date_utils import to_timestamp from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.courseware.courses import get_course_by_id from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_and_subsection_grades from lms.djangoapps.grades.api import constants as grades_constants from lms.djangoapps.grades.api import context as grades_context from lms.djangoapps.grades.api import events as grades_events from lms.djangoapps.grades.api import gradebook_can_see_bulk_management as can_see_bulk_management from lms.djangoapps.grades.api import is_writable_gradebook_enabled, prefetch_course_and_subsection_grades from lms.djangoapps.grades.course_data import CourseData from lms.djangoapps.grades.grade_utils import are_grades_frozen # TODO these imports break abstraction of the core Grades layer. This code needs # to be refactored so Gradebook views only access public Grades APIs. from lms.djangoapps.grades.models import ( PersistentCourseGrade, PersistentSubsectionGrade, PersistentSubsectionGradeOverride ) from lms.djangoapps.grades.rest_api.serializers import ( StudentGradebookEntrySerializer, SubsectionGradeResponseSerializer ) from lms.djangoapps.grades.rest_api.v1.utils import USER_MODEL, CourseEnrollmentPagination, GradeViewMixin from lms.djangoapps.grades.subsection_grade import CreateSubsectionGrade from lms.djangoapps.grades.subsection_grade_factory import SubsectionGradeFactory from lms.djangoapps.grades.tasks import recalculate_subsection_grade_v3 from lms.djangoapps.program_enrollments.api import get_external_key_by_user_and_course from openedx.core.djangoapps.course_groups import cohorts from openedx.core.djangoapps.util.forms import to_bool from openedx.core.lib.api.view_utils import ( DeveloperErrorViewMixin, PaginatedAPIView, get_course_key, verify_course_exists, view_auth_classes ) from openedx.core.lib.cache_utils import request_cached from xmodule.modulestore.django import modulestore from xmodule.util.misc import get_default_short_labeler log = logging.getLogger(__name__) @contextmanager def bulk_gradebook_view_context(course_key, users): """ Prefetches all course and subsection grades in the given course for the given list of users, also, fetch all the score relavant data, storing the result in a RequestCache and deleting grades on context exit. """ prefetch_course_and_subsection_grades(course_key, users) CourseEnrollment.bulk_fetch_enrollment_states(users, course_key) cohorts.bulk_cache_cohorts(course_key, users) BulkRoleCache.prefetch(users) try: yield finally: clear_prefetched_course_and_subsection_grades(course_key) 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 is_writable_gradebook_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 @view_auth_classes() class BaseCourseView(DeveloperErrorViewMixin, GenericAPIView): """ A base class for course info APIs. TODO: https://openedx.atlassian.net/browse/EDUCATOR-3755 This whole thing is duplicated from cms/djangoapps/contentstore """ @contextmanager def get_course(self, request, course_key): """ Context manager that yields a course, given a request and course_key. """ store = modulestore() with store.bulk_operations(course_key): course = store.get_course(course_key, depth=self._required_course_depth(request)) yield course @staticmethod def _required_course_depth(request): """ Returns how far deep we need to go into the course tree to get all of the information required. Will use entire tree if the request's `all` param is truthy, otherwise goes to depth of 2 (subsections). """ all_requested = get_bool_param(request, 'all', False) if all_requested: return None return 2 @classmethod @request_cached() def _get_visible_subsections(cls, course): """ Returns a list of all visible subsections for a course. """ _, visible_sections = cls._get_sections(course) visible_subsections = [] for section in visible_sections: visible_subsections.extend(cls._get_visible_children(section)) return visible_subsections @classmethod @request_cached() def _get_sections(cls, course): """ Returns all sections in the course. """ return cls._get_all_children(course) @classmethod def _get_all_children(cls, parent): """ Returns all child nodes of the given parent. """ store = modulestore() children = [store.get_item(child_usage_key) for child_usage_key in cls._get_children(parent)] visible_children = [ c for c in children if not c.visible_to_staff_only and not c.hide_from_toc ] return children, visible_children @classmethod def _get_visible_children(cls, parent): """ Returns only the visible children of the given parent. """ _, visible_chidren = cls._get_all_children(parent) return visible_chidren @classmethod def _get_children(cls, parent): """ Returns the value of the 'children' attribute of a node. """ if not hasattr(parent, 'children'): return [] else: return parent.children def get_bool_param(request, param_name, default): """ Given a request, parameter name, and default value, returns either a boolean value or the default. """ param_value = request.query_params.get(param_name, None) bool_value = to_bool(param_value) if bool_value is None: return default else: return bool_value def course_author_access_required(view): """ Ensure the user making the API request has course author access to the given course. This decorator parses the course_id parameter, checks course access, and passes the parsed course_key to the view as a parameter. It will raise a 403 error if the user does not have author access. Usage:: @course_author_access_required def my_view(request, course_key): # Some functionality ... """ def _wrapper_view(self, request, course_id, *args, **kwargs): """ Checks for course author access for the given course by the requesting user. Calls the view function if has access, otherwise raises a 403. """ course_key = CourseKey.from_string(course_id) if not has_course_author_access(request.user, course_key): raise DeveloperErrorViewMixin.api_error( status_code=status.HTTP_403_FORBIDDEN, developer_message='The requesting user does not have course author permissions.', error_code='user_permissions', ) return view(self, request, course_key, *args, **kwargs) return _wrapper_view class CourseGradingView(BaseCourseView): """ Returns information about assignments and assignment types for a course. **Example Requests** GET /api/grades/v1/gradebook/{course_id}/grading-info **GET Parameters** A GET request may include the following parameters. * graded_only (boolean) - If true, only returns subsection data for graded subsections (defaults to False). **GET Response Values** The HTTP 200 response has the following values. * assignment_types - A dictionary keyed by the assignment type name with the following values: * min_count - The minimum number of required assignments of this type. * weight - The weight assigned to this assignment type for course grading. * type - The name of the assignment type. * drop_count - The maximum number of assignments of this type that can be dropped. * short_label - The short label prefix used for short labels of assignments of this type (e.g. 'HW'). * subsections - A list of subsections contained in this course. * module_id - The string version of this subsection's location. * display_name - The display name of this subsection. * graded - Boolean indicating whether this subsection is graded (for at least one user in the course). * short_label - A short label for graded assignments (e.g. 'HW 01'). * assignment_type - The assignment type of this subsection (for graded assignments only). """ @course_author_access_required def get(self, request, course_key): """ Returns grading information (which subsections are graded, assignment types) for the requested course. """ graded_only = get_bool_param(request, 'graded_only', False) with self.get_course(request, course_key) as course: results = { 'grade_cutoffs': course.grade_cutoffs, 'assignment_types': self._get_assignment_types(course), 'subsections': self._get_subsections(course, graded_only), 'grades_frozen': are_grades_frozen(course_key), 'can_see_bulk_management': can_see_bulk_management(course_key), } return Response(results) def _get_assignment_types(self, course): """ Helper function that returns a serialized dict of assignment types for the given course. Args: course - A course object. """ serialized_grading_policies = {} for grader, assignment_type, weight in course.grader.subgraders: serialized_grading_policies[assignment_type] = { 'type': assignment_type, 'short_label': grader.short_label, 'min_count': grader.min_count, 'drop_count': grader.drop_count, 'weight': weight, } return serialized_grading_policies def _get_subsections(self, course, graded_only=False): """ Helper function that returns a list of subsections contained in the given course. Args: course - A course object. graded_only - If true, returns only graded subsections (defaults to False). """ subsections = [] short_labeler = get_default_short_labeler(course) for subsection in self._get_visible_subsections(course): if graded_only and not subsection.graded: continue short_label = None if subsection.graded: short_label = short_labeler(subsection.format) subsections.append({ 'assignment_type': subsection.format, 'graded': subsection.graded, 'short_label': short_label, 'module_id': str(subsection.location), 'display_name': subsection.display_name, }) return subsections @view_auth_classes() 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={username}?history_record_limit={number} - Get grades for specific user in course, only show {number} latest records GET /api/grades/v1/gradebook/{course_id}/?user_contains={user_contains} 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. * user_contains: (optional) A substring against which a case-insensitive substring filter will be performed on the USER_MODEL.username, or the USER_MODEL.email, or the PROGRAM_ENROLLMENT.external_user_key fields. * 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 * progress_page_url: A link to the user's progress page. * section_breakdown: A list of subsection grade details, as specified below. 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** { "user_id": 9, "username": "staff", "percent": 0.36, "section_breakdown": [ { "are_grades_published": true, "auto_grade": false, "category": null, "chapter_name": "Introduction", "comment": "", "detail": "", "displayed_value": "0.00", "grade_description": "(0.00/0.00)", "is_ag": false, "is_average": false, "is_manually_graded": false, "label": 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" }, ], } **Paginated GET response** When requesting gradebook entries for all users, the response is paginated and contains the following values: * 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. * total_users_count: The total number of active users in the course. * filtered_users_count: The total number of active users that match the filter associated with the provided query parameters. Note: It's important that `GradeViewMixin` is the first inherited class here, so that self.api_error returns error responses as expected. """ pagination_class = CourseEnrollmentPagination def _section_breakdown(self, course, graded_subsections, course_grade): """ Given a course_grade and a list of graded subsections for a given course, returns a list of grade data broken down by subsection. Args: course: A Course Descriptor object graded_subsections: A list of graded subsection objects in the given course. course_grade: A CourseGrade object. """ breakdown = [] default_labeler = get_default_short_labeler(course) for subsection in graded_subsections: subsection_grade = course_grade.subsection_grade(subsection.location) short_label = default_labeler(subsection_grade.format) attempted = False score_earned = 0 score_possible = 0 # For ZeroSubsectionGrades, we don't want to crawl the subsection's # subtree to find the problem scores specific to this user # (ZeroSubsectionGrade.attempted_graded is always False). # We've already fetched the whole course structure in a non-user-specific way # when creating `graded_subsections`. Looking at the problem scores # specific to this user (the user in `course_grade.user`) would require # us to re-fetch the user-specific course structure from the modulestore, # which is a costly operation. So we only drill into the `graded_total` # attribute if the user has attempted this graded subsection, or if there # has been a grade override applied. if subsection_grade.attempted_graded or subsection_grade.override: attempted = True score_earned = subsection_grade.graded_total.earned score_possible = subsection_grade.graded_total.possible # TODO: https://openedx.atlassian.net/browse/EDUCATOR-3559 -- Some fields should be renamed, others removed: # 'displayed_value' should maybe be 'description_percent' # 'grade_description' should be 'description_ratio' breakdown.append({ 'attempted': attempted, 'category': subsection_grade.format, 'label': short_label, 'module_id': str(subsection_grade.location), 'percent': subsection_grade.percent_graded, 'score_earned': score_earned, 'score_possible': score_possible, 'subsection_name': subsection_grade.display_name, }) return breakdown def _gradebook_entry(self, user, course, graded_subsections, 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. graded_subsections: A list of graded subsections in the given course. course_grade: A CourseGrade object. """ user_entry = self._serialize_user_grade(user, course.id, course_grade) breakdown = self._section_breakdown(course, graded_subsections, course_grade) user_entry['section_breakdown'] = breakdown user_entry['progress_page_url'] = reverse( 'student_progress', kwargs=dict(course_id=str(course.id), student_id=user.id) ) user_entry['user_id'] = user.id user_entry['full_name'] = user.profile.name external_user_key = get_external_key_by_user_and_course(user, course.id) if external_user_key: user_entry['external_user_key'] = external_user_key return user_entry @verify_course_exists @verify_writable_gradebook_enabled @course_author_access_required def get(self, request, course_key): # lint-amnesty, pylint: disable=too-many-statements """ 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_key: The edx course opaque key of a course object. """ course = get_course_by_id(course_key, depth=None) # We fetch the entire course structure up-front, and use this when iterating # over users to determine their subsection grades. We purposely avoid fetching # the user-specific course structure for each user, because that is very expensive. course_data = CourseData(user=None, course=course) graded_subsections = list(grades_context.graded_subsections_for_course(course_data.collected_structure)) if request.GET.get('username'): with self._get_user_or_raise(request, course_key) as grade_user: course_grade = CourseGradeFactory().read( grade_user, course, collected_block_structure=course_data.collected_structure ) entry = self._gradebook_entry(grade_user, course, graded_subsections, course_grade) serializer = StudentGradebookEntrySerializer(entry) return Response(serializer.data) else: q_objects = [] annotations = {} if request.GET.get('user_contains'): search_term = request.GET.get('user_contains') q_objects.append( Q(user__username__icontains=search_term) | Q(programcourseenrollment__program_enrollment__external_user_key__icontains=search_term) | Q(user__email__icontains=search_term) ) if request.GET.get('username_contains'): q_objects.append(Q(user__username__icontains=request.GET.get('username_contains'))) if request.GET.get('cohort_id'): cohort = cohorts.get_cohort_by_id(course_key, request.GET.get('cohort_id')) if cohort: q_objects.append(Q(user__in=cohort.users.all())) else: q_objects.append(Q(user__in=[])) if request.GET.get('enrollment_mode'): q_objects.append(Q(mode=request.GET.get('enrollment_mode'))) if request.GET.get('assignment') and ( request.GET.get('assignment_grade_max') or request.GET.get('assignment_grade_min')): subqueryset = PersistentSubsectionGrade.objects.annotate( effective_grade_percentage=Case( When(override__isnull=False, then=( F('override__earned_graded_override') / F('override__possible_graded_override') ) * 100), default=(F('earned_graded') / F('possible_graded')) * 100 ) ) grade_conditions = { 'effective_grade_percentage__range': ( request.GET.get('assignment_grade_min', 0), request.GET.get('assignment_grade_max', 100) ) } annotations['selected_assignment_grade_in_range'] = Exists( subqueryset.filter( course_id=OuterRef('course'), user_id=OuterRef('user'), usage_key=UsageKey.from_string(request.GET.get('assignment')), **grade_conditions ) ) q_objects.append(Q(selected_assignment_grade_in_range=True)) if request.GET.get('course_grade_min') or request.GET.get('course_grade_max'): grade_conditions = {} q_object = Q() course_grade_min = request.GET.get('course_grade_min') if course_grade_min: course_grade_min = float(request.GET.get('course_grade_min')) / 100 grade_conditions['percent_grade__gte'] = course_grade_min if request.GET.get('course_grade_max'): course_grade_max = float(request.GET.get('course_grade_max')) / 100 grade_conditions['percent_grade__lte'] = course_grade_max if not course_grade_min or course_grade_min == 0: subquery_grade_absent = ~Exists( PersistentCourseGrade.objects.filter( course_id=OuterRef('course'), user_id=OuterRef('user_id'), ) ) annotations['course_grade_absent'] = subquery_grade_absent q_object |= Q(course_grade_absent=True) subquery_grade_in_range = Exists( PersistentCourseGrade.objects.filter( course_id=OuterRef('course'), user_id=OuterRef('user_id'), **grade_conditions ) ) annotations['course_grade_in_range'] = subquery_grade_in_range q_object |= Q(course_grade_in_range=True) q_objects.append(q_object) if request.GET.get('excluded_course_roles'): excluded_course_roles = request.GET.getlist('excluded_course_roles') course_access_role_filters = dict( user=OuterRef('user'), course_id=course_key, ) if 'all' not in excluded_course_roles: course_access_role_filters['role__in'] = excluded_course_roles annotations['has_excluded_role'] = Exists( CourseAccessRole.objects.filter(**course_access_role_filters) ) # TODO: In django 3.0+, we can directly filter on this 'exists' rather than annotating q_objects.append(Q(has_excluded_role=False)) entries = [] related_models = ['user'] users = self._paginate_users(course_key, q_objects, related_models, annotations=annotations) users_counts = self._get_users_counts(course_key, q_objects, annotations=annotations) with bulk_gradebook_view_context(course_key, users): for user, course_grade, exc in CourseGradeFactory().iter( users, course_key=course_key, collected_block_structure=course_data.collected_structure ): if not exc: entry = self._gradebook_entry(user, course, graded_subsections, course_grade) entries.append(entry) serializer = StudentGradebookEntrySerializer(entries, many=True) return self.get_paginated_response(serializer.data, **users_counts) def _get_user_count(self, query_args, cache_time=3600, annotations=None): """ Return the user count for the given query arguments to CourseEnrollment. caches the count for cache_time seconds. """ queryset = CourseEnrollment.objects if annotations: queryset = queryset.annotate(**annotations) queryset = queryset.filter(*query_args) cache_key = 'usercount.%s' % queryset.query user_count = cache.get(cache_key, None) if user_count is None: user_count = queryset.count() cache.set(cache_key, user_count, cache_time) return user_count def _get_users_counts(self, course_key, course_enrollment_filters, annotations=None): """ Return a dictionary containing data about the total number of users and total number of users matching a given filter in a given course. Arguments: course_key: the opaque key for the course course_enrollment_filters: a list of Q objects representing filters to be applied to CourseEnrollments annotations: Optional dict of fields to add to the queryset via annotation Returns: dict: total_users_count: the number of total active users in the course filtered_users_count: the number of active users in the course that match the given course_enrollment_filters """ filter_args = [ Q(course_id=course_key) & Q(is_active=True) ] total_users_count = self._get_user_count(filter_args) filter_args.extend(course_enrollment_filters or []) # if course_enrollment_filters is empty, then the number of filtered users will equal the total number of users filtered_users_count = ( total_users_count if not course_enrollment_filters else self._get_user_count(filter_args, annotations=annotations) ) return { 'total_users_count': total_users_count, 'filtered_users_count': filtered_users_count, } GradebookUpdateResponseItem = namedtuple('GradebookUpdateResponseItem', ['user_id', 'usage_id', 'success', 'reason']) @view_auth_classes() 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, "comment": "reason for override" } }, { "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, "comment": "reason for override" } } ] **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": ": 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." } ] """ @verify_course_exists @verify_writable_gradebook_enabled @course_author_access_required def post(self, request, course_key): """ 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. """ 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_by_id(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: self._log_update_result(request.user, requested_user_id, requested_usage_id, success=False) result.append(GradebookUpdateResponseItem( user_id=requested_user_id, usage_id=requested_usage_id, success=False, reason=str(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) # TODO: Remove as part of EDUCATOR-4602. if str(course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': log.info('PersistentSubsectionGrade ***{}*** created for' ' subsection ***{}*** in course ***{}*** for user ***{}***.' .format(subsection_grade_model, subsection.location, course, user.id)) else: self._log_update_result(request.user, requested_user_id, requested_usage_id, success=False) result.append(GradebookUpdateResponseItem( user_id=requested_user_id, usage_id=requested_usage_id, success=False, reason=f'usage_key {usage_key} does not exist in this course.' )) continue if subsection_grade_model: override = self._create_override(request.user, subsection_grade_model, **user_data['grade']) self._log_update_result( request.user, requested_user_id, requested_usage_id, subsection_grade_model, override, True ) result.append(GradebookUpdateResponseItem( user_id=user.id, usage_id=str(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, request_user, subsection_grade_model, **override_data): """ Helper method to create a `PersistentSubsectionGradeOverride` object and send a `SUBSECTION_OVERRIDE_CHANGED` signal. """ override_data['system'] = grades_constants.GradeOverrideFeatureEnum.gradebook override = PersistentSubsectionGradeOverride.update_or_create_override( requesting_user=request_user, subsection_grade_model=subsection_grade_model, feature=grades_constants.GradeOverrideFeatureEnum.gradebook, **override_data ) set_event_transaction_type(grades_events.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=str(subsection_grade_model.course_id), usage_id=str(subsection_grade_model.usage_key), only_if_higher=False, expected_modified_time=to_timestamp(override.modified), score_deleted=False, event_transaction_id=str(get_event_transaction_id()), event_transaction_type=str(get_event_transaction_type()), score_db_table=grades_constants.ScoreDatabaseTableEnum.overrides, force_update_subsections=True, ) ) # Emit events to let our tracking system to know we updated subsection grade grades_events.subsection_grade_calculated(subsection_grade_model) return override @staticmethod def _log_update_result( # lint-amnesty, pylint: disable=missing-function-docstring request_user, user_id, usage_id, subsection_grade_model=None, subsection_grade_override=None, success=False ): log.info( 'Grades: Bulk_Update, UpdatedByUser: %s, User: %s, Usage: %s, Grade: %s, GradeOverride: %s, Success: %s', request_user.id, user_id, usage_id, subsection_grade_model, subsection_grade_override, success ) class SubsectionUnavailableToUserException(Exception): pass @view_auth_classes() class SubsectionGradeView(GradeViewMixin, APIView): """ **Use Case** * This api is to get information about a users original grade for a subsection. It also exposes any overrides that now replace the original grade and a history of user changes with time stamps of all changes. **Example Request** GET /api/grades/v1/subsection/{subsection_id}/?user_id={user_id} **GET Parameters** A GET request may include the following query parameters. * user_id: (required) An integer represenation of a user **GET Response Values** If the request for subsection grade data is successful, an HTTP 200 "OK" response is returned. The HTTP 200 response has the following values: * subsection_id: A string representation of the usage_key for a course subsection * user_id: The user's integer id * course_id: A string representation of a Course ID. * original_grade: An object representation of a users original grade containing: * earned_all: The float score a user earned for all graded and not graded problems * possible_all: The float highest score a user can earn for all graded and not graded problems * earned_graded: The float score a user earned for only graded probles * possible_graded: The float highest score a user can earn for only graded problems * override: An object representation of an over ride for a user's subsection grade containing: * earned_all_override: The float overriden score a user earned for all graded and not graded problems * possible_all_override: The float overriden highest score a user can earn for all graded and not graded problems * earned_graded_override: The float overriden grade a user earned for only graded problems * possible_graded_override: The float overriden highest possible grade a user can earn for only graded problems * history: A list of history objects that contain * user: The string representation of the user who was responsible for overriding the grade * comments: A string comment about why that person changed the grade * created: The date timestamp the grade was changed * feature: The string representation of the feature through which the grade was overrriden * action: The string representation of the CRUD action the override did An HTTP 404 may be returned for the following reasons: * The requested subsection_id is invalid. * The requested user_id is invalid. * NOTE: if you pass in a valid subsection_id and a valid user_id with no data representation in the DB then you will still recieve a 200 with a response with 'original_grade', 'override' and 'course_id' set to None and the 'history' list will be empty. **Example GET Response** { "subsection_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions", "user_id": 2, "course_id": "course-v1:edX+DemoX+Demo_Course", "original_grade": { "earned_all": 0, "possible_all": 11, "earned_graded": 8, "possible_graded": 11 }, "override": { "earned_all_override": null, "possible_all_override": null, "earned_graded_override": 8, "possible_graded_override": null }, "history": [ { "user": "edx", "comments": null, "created": "2018-12-03T18:52:36.087134Z", "feature": "GRADEBOOK", "action": "CREATEORUPDATE" }, { "user": "edx", "comments": null, "created": "2018-12-03T20:41:02.507685Z", "feature": "GRADEBOOK", "action": "CREATEORUPDATE" }, { "user": "edx", "comments": null, "created": "2018-12-03T20:46:08.933387Z", "feature": "GRADEBOOK", "action": "CREATEORUPDATE" } ] } """ def get(self, request, subsection_id): """ Returns subection grade data, override grade data and a history of changes made to a specific users specific subsection grade. Args: subsection_id: String representation of a usage_key, which is an opaque key of a persistant subection grade. user_id: An integer represenation of a user """ try: usage_key = UsageKey.from_string(subsection_id) except InvalidKeyError: raise self.api_error( # lint-amnesty, pylint: disable=raise-missing-from status_code=status.HTTP_404_NOT_FOUND, developer_message='Invalid UsageKey', error_code='invalid_usage_key' ) if not has_course_author_access(request.user, usage_key.course_key): raise DeveloperErrorViewMixin.api_error( status_code=status.HTTP_403_FORBIDDEN, developer_message='The requesting user does not have course author permissions.', error_code='user_permissions', ) try: user_id = int(request.GET.get('user_id')) except ValueError: raise self.api_error( # lint-amnesty, pylint: disable=raise-missing-from status_code=status.HTTP_404_NOT_FOUND, developer_message='Invalid UserID', error_code='invalid_user_id' ) success = True err_msg = "" override = None history = [] history_record_limit = request.GET.get('history_record_limit') if history_record_limit is not None: try: history_record_limit = int(history_record_limit) except ValueError: history_record_limit = 0 try: original_grade = PersistentSubsectionGrade.read_grade(user_id, usage_key) if original_grade is not None and hasattr(original_grade, 'override'): override = original_grade.override # pylint: disable=no-member history = list(PersistentSubsectionGradeOverride.history.filter(grade_id=original_grade.id).order_by( 'history_date' )[:history_record_limit]) grade_data = { 'earned_all': original_grade.earned_all, 'possible_all': original_grade.possible_all, 'earned_graded': original_grade.earned_graded, 'possible_graded': original_grade.possible_graded, } except PersistentSubsectionGrade.DoesNotExist: try: grade_data = self._get_grade_data_for_not_attempted_assignment(user_id, usage_key) except SubsectionUnavailableToUserException as exc: success = False err_msg = str(exc) grade_data = { 'earned_all': 0, 'possible_all': 0, 'earned_graded': 0, 'possible_graded': 0, } response_data = { 'success': success, 'original_grade': grade_data, 'override': override, 'history': history, 'subsection_id': usage_key, 'user_id': user_id, 'course_id': usage_key.course_key, } if not success: response_data['error_message'] = err_msg results = SubsectionGradeResponseSerializer(response_data) return Response(results.data) def _get_grade_data_for_not_attempted_assignment(self, user_id, usage_key): """ Return grade for an assignment that wasn't attempted """ student = get_user_model().objects.get(id=user_id) course_structure = get_course_blocks(student, usage_key) if usage_key not in course_structure: raise SubsectionUnavailableToUserException( _("Cannot override subsection grade: subsection is not available for target learner.") ) subsection_grade_factory = SubsectionGradeFactory(student, course_structure=course_structure) grade = subsection_grade_factory.create(course_structure[usage_key], read_only=True, force_calculate=True) grade_data = { 'earned_all': grade.all_total.earned, 'possible_all': grade.all_total.possible, 'earned_graded': grade.graded_total.earned, 'possible_graded': grade.graded_total.possible, } return grade_data