From cf6a646b32ca362d3b06dfa14fbda8e8d0e78002 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Thu, 1 Oct 2026 00:25:54 +0500 Subject: [PATCH 1/6] feat: publish the Grades API in the LMS schema Admit /api/grade/v2/ and /api/grades/v1/ to the LMS OpenAPI document, and mark every /api/grades/v1/ operation deprecated through the existing post-processing hook instead of adding a second one. --- lms/lib/spectacular.py | 36 +++++++++++++++++++++---------- lms/lib/tests/test_spectacular.py | 26 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 942199deac69..012e61ea351d 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -11,24 +11,38 @@ # addresses already have distinct ids and keep them. COLLIDING_LEGACY_PATH = "/api/enrollment/v2/enrollments" +# Mounts published in the LMS schema. Anything else is left out. +SCHEMA_PATH_PATTERNS = ( + re.compile(r"^/api/enrollment/v\d+/"), + re.compile(r"^/api/grade/v\d+/"), + re.compile(r"^/api/grades/v1/"), +) + +# Whole API versions a newer version supersedes; every operation under them is deprecated. +SUPERSEDED_PATH_PREFIXES = ( + "/api/grades/v1/", +) + def _is_legacy_path(path): """Conforming routes always end in a slash, so a slashless v2 path is a legacy address.""" return path.startswith(LEGACY_V2_PREFIX) and not path.endswith("/") +def _is_deprecated_path(path): + """Return True for a legacy address or any address under a superseded version.""" + return _is_legacy_path(path) or path.startswith(SUPERSEDED_PATH_PREFIXES) + + def lms_api_filter(endpoints): """ - Pre-processing hook: keep only enrollment v2 endpoints tagged for the SDK. + Pre-processing hook: keep only the endpoints under the mounts the LMS schema publishes. """ - filtered = [] - ENROLLMENT_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") - - for path, path_regex, method, callback in endpoints: - if ENROLLMENT_PATH_PATTERN.match(path): - filtered.append((path, path_regex, method, callback)) - - return filtered + return [ + (path, path_regex, method, callback) + for path, path_regex, method, callback in endpoints + if any(pattern.match(path) for pattern in SCHEMA_PATH_PATTERNS) + ] class LmsAutoSchema(AutoSchema): @@ -50,9 +64,9 @@ def get_operation_id(self): def lms_mark_legacy_paths_deprecated(result, generator, request, public): # pylint: disable=unused-argument - """Mark the legacy slashless Enrollment v2 addresses ``deprecated: true``.""" + """Mark the legacy slashless Enrollment v2 addresses and superseded versions ``deprecated: true``.""" for path, path_item in result.get("paths", {}).items(): - if not _is_legacy_path(path): + if not _is_deprecated_path(path): continue for operation in path_item.values(): if isinstance(operation, dict): diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index b611c8ad7776..a73df527b27a 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -35,6 +35,19 @@ def test_keeps_enrollment_drops_the_rest(self): "/api/enrollment/v1/enrollment", ] + def test_keeps_grade_versions_and_superseded_grades_v1(self): + kept = lms_api_filter([ + _endpoint("/api/grade/v2/course_grades/"), + _endpoint("/api/grades/v1/courses/"), + _endpoint("/api/grades/v2/courses/"), + _endpoint("/api/grade/courses/"), + _endpoint("/api/user/v1/accounts/"), + ]) + assert [path for path, *_ in kept] == [ + "/api/grade/v2/course_grades/", + "/api/grades/v1/courses/", + ] + class LmsMarkLegacyPathsDeprecatedTest(SimpleTestCase): """Slashless v2 paths are legacy and deprecated; slashed ones are conforming.""" @@ -54,6 +67,19 @@ def test_only_slashless_v2_paths_are_deprecated(self): assert "deprecated" not in paths["/api/enrollment/v2/courses/{course_key}/"]["get"] assert "deprecated" not in paths["/api/enrollment/v1/enrollment"]["get"] + def test_superseded_grades_v1_paths_are_deprecated(self): + result = lms_mark_legacy_paths_deprecated(_schema( + "/api/grades/v1/courses/", + "/api/grades/v1/gradebook/{course_id}/bulk-update", + "/api/grade/v2/course_grades/", + "/api/grade/v2/courses/{course_key}/gradebook_entries/", + ), None, None, False) + paths = result["paths"] + assert paths["/api/grades/v1/courses/"]["get"]["deprecated"] is True + assert paths["/api/grades/v1/gradebook/{course_id}/bulk-update"]["get"]["deprecated"] is True + assert "deprecated" not in paths["/api/grade/v2/course_grades/"]["get"] + assert "deprecated" not in paths["/api/grade/v2/courses/{course_key}/gradebook_entries/"]["get"] + def test_paths_are_left_as_full_urls(self): """Paths are not trimmed, so every key resolves against LMS_ROOT_URL.""" result = lms_mark_legacy_paths_deprecated(_schema("/api/enrollment/v2/enrollments/"), None, None, False) From d57709494a450ad5fe663257ebce27f98ec82c4b Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Thu, 1 Oct 2026 00:25:55 +0500 Subject: [PATCH 2/6] feat: add the Grades API v2 at /api/grade/v2/ Grades v1 had no standardized endpoint: bare error bodies, cursor pagination, Bearer auth, numeric-id addresses, verb paths and 200 {"success": false}. Fixing any of it changes the contract, so v2 is a new version and v1 stays mounted as it is. Nine operations replace the nine v1 routes: course grades (with the staff-only section breakdown as ?view=full), gradebook entries, all-or-nothing subsection grade overrides, subsection grades and their override history, the grading policy, and submission histories. Every v1 permission check maps to a permission class, and tests compare v2 with v1 caller by caller, CCX included. Parity tests compare v1 and v2 responses and fail on any undeclared difference. --- lms/djangoapps/grades/rest_api/v2/__init__.py | 1 + lms/djangoapps/grades/rest_api/v2/errors.py | 34 + lms/djangoapps/grades/rest_api/v2/filters.py | 361 ++++++ .../grades/rest_api/v2/pagination.py | 52 + .../grades/rest_api/v2/permissions.py | 153 +++ lms/djangoapps/grades/rest_api/v2/scoping.py | 57 + .../grades/rest_api/v2/serializers.py | 552 +++++++++ lms/djangoapps/grades/rest_api/v2/services.py | 574 ++++++++++ .../grades/rest_api/v2/tests/__init__.py | 0 .../grades/rest_api/v2/tests/mixins.py | 307 +++++ .../grades/rest_api/v2/tests/parity.py | 95 ++ .../rest_api/v2/tests/test_course_grades.py | 1011 +++++++++++++++++ .../v2/tests/test_course_grading_policy.py | 575 ++++++++++ .../v2/tests/test_gradebook_entries.py | 808 +++++++++++++ .../rest_api/v2/tests/test_infrastructure.py | 287 +++++ .../grades/rest_api/v2/tests/test_schema.py | 324 ++++++ .../v2/tests/test_submission_histories.py | 406 +++++++ .../tests/test_subsection_grade_overrides.py | 627 ++++++++++ .../v2/tests/test_subsection_grades.py | 597 ++++++++++ .../grades/rest_api/v2/tests/test_urls.py | 217 ++++ lms/djangoapps/grades/rest_api/v2/urls.py | 73 ++ .../grades/rest_api/v2/views/__init__.py | 0 .../grades/rest_api/v2/views/course_grades.py | 178 +++ .../v2/views/course_grading_policy.py | 100 ++ .../rest_api/v2/views/gradebook_entries.py | 165 +++ .../rest_api/v2/views/submission_histories.py | 102 ++ .../v2/views/subsection_grade_overrides.py | 160 +++ .../rest_api/v2/views/subsection_grades.py | 156 +++ .../grades/rest_api/v2/views/unmatched.py | 28 + lms/urls.py | 3 + 30 files changed, 8003 insertions(+) create mode 100644 lms/djangoapps/grades/rest_api/v2/__init__.py create mode 100644 lms/djangoapps/grades/rest_api/v2/errors.py create mode 100644 lms/djangoapps/grades/rest_api/v2/filters.py create mode 100644 lms/djangoapps/grades/rest_api/v2/pagination.py create mode 100644 lms/djangoapps/grades/rest_api/v2/permissions.py create mode 100644 lms/djangoapps/grades/rest_api/v2/scoping.py create mode 100644 lms/djangoapps/grades/rest_api/v2/serializers.py create mode 100644 lms/djangoapps/grades/rest_api/v2/services.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/__init__.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/mixins.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/parity.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_course_grading_policy.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_infrastructure.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_schema.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_submission_histories.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grade_overrides.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grades.py create mode 100644 lms/djangoapps/grades/rest_api/v2/tests/test_urls.py create mode 100644 lms/djangoapps/grades/rest_api/v2/urls.py create mode 100644 lms/djangoapps/grades/rest_api/v2/views/__init__.py create mode 100644 lms/djangoapps/grades/rest_api/v2/views/course_grades.py create mode 100644 lms/djangoapps/grades/rest_api/v2/views/course_grading_policy.py create mode 100644 lms/djangoapps/grades/rest_api/v2/views/gradebook_entries.py create mode 100644 lms/djangoapps/grades/rest_api/v2/views/submission_histories.py create mode 100644 lms/djangoapps/grades/rest_api/v2/views/subsection_grade_overrides.py create mode 100644 lms/djangoapps/grades/rest_api/v2/views/subsection_grades.py create mode 100644 lms/djangoapps/grades/rest_api/v2/views/unmatched.py diff --git a/lms/djangoapps/grades/rest_api/v2/__init__.py b/lms/djangoapps/grades/rest_api/v2/__init__.py new file mode 100644 index 000000000000..80e2c51fe052 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/__init__.py @@ -0,0 +1 @@ +"""Grades API v2; `/api/grades/v1/` is unchanged and deprecated.""" diff --git a/lms/djangoapps/grades/rest_api/v2/errors.py b/lms/djangoapps/grades/rest_api/v2/errors.py new file mode 100644 index 000000000000..ac045aee7618 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/errors.py @@ -0,0 +1,34 @@ +"""Error types the Grades API v2 can return.""" + +from edx_rest_framework_extensions.errors import register_error_type +from rest_framework.exceptions import NotFound, PermissionDenied + + +class WritableGradebookDisabled(PermissionDenied): + """The writable gradebook is switched off for the course being addressed.""" + + default_detail = 'The writable gradebook is not enabled for this course.' + default_code = 'writable_gradebook_disabled' + + +class GradesFrozen(PermissionDenied): + """The course ended long enough ago that its grades may no longer be changed.""" + + default_detail = 'Grades are frozen for this course.' + default_code = 'grades_frozen' + + +class SubsectionUnavailable(NotFound): + """The subsection addressed is not available to the learner addressed.""" + + default_detail = 'The subsection is not available to this learner.' + default_code = 'subsection_unavailable' + + +register_error_type( + WritableGradebookDisabled, + 'grades/writable-gradebook-disabled', + 'Writable Gradebook Disabled', +) +register_error_type(GradesFrozen, 'grades/grades-frozen', 'Grades Frozen') +register_error_type(SubsectionUnavailable, 'grades/subsection-unavailable', 'Subsection Unavailable') diff --git a/lms/djangoapps/grades/rest_api/v2/filters.py b/lms/djangoapps/grades/rest_api/v2/filters.py new file mode 100644 index 000000000000..d62cde79102d --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/filters.py @@ -0,0 +1,361 @@ +"""Filter sets for the Grades API v2 list endpoints.""" + +from django import forms +from django.db.models import Case, Exists, F, OuterRef, Q, When +from django_filters import rest_framework as filters +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey + +from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment +from lms.djangoapps.grades.models import ( + PersistentCourseGrade, + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, +) +from openedx.core.djangoapps.course_groups.models import CourseUserGroup + +#: Most learners one request may name in the ``username`` filter. +MAX_USERNAMES = 100 + +#: Value of ``excluded_course_roles`` that leaves out learners holding any role. +ALL_COURSE_ROLES = 'all' + + +class CharInFilter(filters.BaseInFilter, filters.CharFilter): + """Matches any of a comma-separated list of values.""" + + +class StableOrderingFilter(filters.OrderingFilter): + """An ordering filter that breaks ties on the primary key, so pages never overlap.""" + + def filter(self, qs, value): + """Order by the requested fields, then by id.""" + qs = super().filter(qs, value) + if value: + qs = qs.order_by(*qs.query.order_by, 'id') + return qs + + +class HistoryOrderingFilter(filters.OrderingFilter): + """An ordering filter for history records that breaks ties on the record id, in the same direction.""" + + def filter(self, qs, value): + """Order by the requested fields, then by history id, descending when the last field is.""" + qs = super().filter(qs, value) + if value: + last = qs.query.order_by[-1] + qs = qs.order_by(*qs.query.order_by, '-history_id' if last.startswith('-') else 'history_id') + return qs + + +def parse_course_key(value): + """Return the CourseKey ``value`` names, or raise a form validation error.""" + try: + course_key = CourseKey.from_string(value) + except InvalidKeyError as exc: + raise forms.ValidationError('Not a valid course key.', code='invalid') from exc + if course_key.deprecated: + raise forms.ValidationError('Not a valid course key.', code='invalid') + return course_key + + +class CourseKeyField(forms.CharField): + """A form field that accepts a course key and cleans it to a CourseKey.""" + + def clean(self, value): + """Return the parsed key, or None when no value was sent.""" + value = super().clean(value) + if not value: + return None + return parse_course_key(value) + + +class CourseKeyFilter(filters.Filter): + """Narrows to the rows of one course, named by its course key.""" + + field_class = CourseKeyField + + +class UsageKeyField(forms.CharField): + """A form field that accepts a usage key and cleans it to a UsageKey.""" + + def clean(self, value): + """Return the parsed key, or None when no value was sent.""" + value = super().clean(value) + if not value: + return None + try: + usage_key = UsageKey.from_string(value) + except InvalidKeyError as exc: + raise forms.ValidationError('Not a valid usage key.', code='invalid') from exc + if usage_key.deprecated: + raise forms.ValidationError('Not a valid usage key.', code='invalid') + return usage_key + + +class UsageKeyFilter(filters.CharFilter): + """A filter whose value is a usage key.""" + + field_class = UsageKeyField + + +class IntegerFilter(filters.NumberFilter): + """A filter whose value is a whole number.""" + + field_class = forms.IntegerField + + +class RepeatedCharField(forms.Field): + """A form field that collects every value of a query parameter sent more than once.""" + + widget = forms.MultipleHiddenInput + + def to_python(self, value): + """Return the non-empty values sent, as a list of strings.""" + return [str(item) for item in value or () if item] + + +class RepeatedCharFilter(filters.MultipleChoiceFilter): + """A filter whose parameter may be repeated, each value free text rather than one of fixed choices.""" + + field_class = RepeatedCharField + + +class CourseGradeFilterForm(forms.Form): + """Validates the course-grade filters that need more than a per-field check.""" + + def clean_username(self): + """Refuse more usernames than one request may name.""" + usernames = self.cleaned_data.get('username') + if usernames and len(usernames) > MAX_USERNAMES: + raise forms.ValidationError( + f'Name at most {MAX_USERNAMES} usernames.', code='max_usernames', + ) + return usernames + + +class CourseGradeFilterSet(filters.FilterSet): + """Narrows a course-grade list to a course and a set of learners, and orders it.""" + + course_key = CourseKeyFilter( + field_name='course_id', + help_text='Return only grades in the course with this key.', + ) + username = CharInFilter( + field_name='user__username', + lookup_expr='in', + help_text=( + 'Return only the grades of these learners: a comma-separated list of ' + f'at most {MAX_USERNAMES} usernames.' + ), + ) + ordering = StableOrderingFilter( + fields=( + ('user__username', 'username'), + ('created', 'created'), + ('id', 'id'), + ), + help_text='Order the results by username, created or id. Prefix with "-" to reverse. Defaults to id.', + ) + + class Meta: + model = CourseEnrollment + fields = [] + form = CourseGradeFilterForm + + +class GradebookEntryFilterSet(filters.FilterSet): + """ + Narrows the gradebook of one course to the learners a caller asked for. + + The queryset is already limited to the course, so every filter only narrows + it. The assignment and course-grade bounds qualify one another, so they are + applied together after the single-value filters rather than one by one. + """ + + user_contains = filters.CharFilter( + method='filter_user_contains', + help_text='Return learners whose username, email address or program key contains this text.', + ) + username_contains = filters.CharFilter( + field_name='user__username', + lookup_expr='icontains', + help_text='Return learners whose username contains this text.', + ) + cohort_id = IntegerFilter( + method='filter_cohort_id', + help_text='Return learners in the cohort of this course with this id.', + ) + enrollment_mode = filters.CharFilter( + field_name='mode', + help_text="Return learners enrolled in this mode, for example 'masters'.", + ) + excluded_course_roles = RepeatedCharFilter( + method='filter_excluded_course_roles', + help_text=( + "Leave out learners who hold this role in the course. Repeat the parameter for several " + f"roles, or send '{ALL_COURSE_ROLES}' to leave out every learner who holds any role." + ), + ) + assignment_usage_key = UsageKeyFilter( + method='filter_applied_together', + help_text='Key of the subsection that assignment_grade_min and assignment_grade_max apply to.', + ) + assignment_grade_min = filters.NumberFilter( + method='filter_applied_together', + help_text='Lowest percentage, from 0 to 100, scored on the subsection named by assignment_usage_key.', + ) + assignment_grade_max = filters.NumberFilter( + method='filter_applied_together', + help_text='Highest percentage, from 0 to 100, scored on the subsection named by assignment_usage_key.', + ) + course_grade_min = filters.NumberFilter( + method='filter_applied_together', + help_text=( + 'Lowest course percentage, from 0 to 100. A bound of 0, or no bound, ' + 'also returns learners who have no stored course grade yet.' + ), + ) + course_grade_max = filters.NumberFilter( + method='filter_applied_together', + help_text='Highest course percentage, from 0 to 100.', + ) + ordering = StableOrderingFilter( + fields=( + ('user__username', 'username'), + ('created', 'created'), + ('id', 'id'), + ), + help_text='Order the results by username, created or id. Prefix with "-" to reverse. Defaults to id.', + ) + + class Meta: + model = CourseEnrollment + fields = [] + + def filter_queryset(self, queryset): + """Apply the single-value filters, then the bounds that qualify each other.""" + queryset = super().filter_queryset(queryset) + queryset = self._filter_by_assignment_grade(queryset) + return self._filter_by_course_grade(queryset) + + def filter_applied_together(self, queryset, name, value): # pylint: disable=unused-argument + """Leave the queryset alone; ``filter_queryset`` applies these values together.""" + return queryset + + def filter_user_contains(self, queryset, name, value): # pylint: disable=unused-argument + """Narrow to learners whose username, email address or program key contains the text.""" + return queryset.filter( + Q(user__username__icontains=value) + | Q(programcourseenrollment__program_enrollment__external_user_key__icontains=value) + | Q(user__email__icontains=value) + ) + + def filter_cohort_id(self, queryset, name, value: int): # pylint: disable=unused-argument + """Narrow to learners in the given cohort of the enrollment's own course.""" + return queryset.filter( + user__course_groups__id=value, + user__course_groups__course_id=F('course_id'), + user__course_groups__group_type=CourseUserGroup.COHORT, + ) + + def filter_excluded_course_roles(self, queryset, name, value: list[str]): # pylint: disable=unused-argument + """Leave out learners holding one of the named roles in the course, or any role.""" + role_filters = {'user': OuterRef('user'), 'course_id': OuterRef('course_id')} + if ALL_COURSE_ROLES not in value: + role_filters['role__in'] = value + return queryset.annotate( + has_excluded_role=Exists(CourseAccessRole.objects.filter(**role_filters)), + ).filter(has_excluded_role=False) + + def _filter_by_assignment_grade(self, queryset): + """Narrow to learners whose score on one subsection falls within the requested bounds.""" + usage_key = self.form.cleaned_data.get('assignment_usage_key') + minimum = self.form.cleaned_data.get('assignment_grade_min') + maximum = self.form.cleaned_data.get('assignment_grade_max') + if not usage_key or (minimum is None and maximum is None): + return queryset + effective_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, + ) + subsection_grades = PersistentSubsectionGrade.objects.annotate( + effective_grade_percentage=effective_percentage, + ).filter( + course_id=OuterRef('course_id'), + user_id=OuterRef('user_id'), + usage_key=usage_key, + effective_grade_percentage__range=( + 0 if minimum is None else minimum, + 100 if maximum is None else maximum, + ), + ) + return queryset.annotate( + selected_assignment_grade_in_range=Exists(subsection_grades), + ).filter(selected_assignment_grade_in_range=True) + + def _filter_by_course_grade(self, queryset): + """Narrow to learners whose course grade falls within the requested bounds.""" + minimum = self.form.cleaned_data.get('course_grade_min') + maximum = self.form.cleaned_data.get('course_grade_max') + if minimum is None and maximum is None: + return queryset + bounds = {} + if minimum is not None: + bounds['percent_grade__gte'] = float(minimum) / 100 + if maximum is not None: + bounds['percent_grade__lte'] = float(maximum) / 100 + annotations = { + 'course_grade_in_range': Exists(PersistentCourseGrade.objects.filter( + course_id=OuterRef('course_id'), user_id=OuterRef('user_id'), **bounds, + )), + } + matches = Q(course_grade_in_range=True) + if not minimum: + # A learner with no stored course grade has a grade of zero. + annotations['course_grade_absent'] = ~Exists(PersistentCourseGrade.objects.filter( + course_id=OuterRef('course_id'), user_id=OuterRef('user_id'), + )) + matches |= Q(course_grade_absent=True) + return queryset.annotate(**annotations).filter(matches) + + +class SubsectionGradeOverrideHistoryFilterSet(filters.FilterSet): + """Orders the override history of one subsection grade.""" + + ordering = HistoryOrderingFilter( + fields=(('history_date', 'history_date'),), + help_text='Order the records by history_date. Prefix with "-" to reverse. Defaults to newest first.', + ) + + class Meta: + model = PersistentSubsectionGradeOverride.history.model + fields = [] + + +class SubmissionHistoryFilterSet(filters.FilterSet): + """Narrows the submission histories of one course to a set of learners, and orders them.""" + + username = CharInFilter( + field_name='user__username', + lookup_expr='in', + help_text=( + 'Return only the histories of these learners: a comma-separated list of ' + f'at most {MAX_USERNAMES} usernames.' + ), + ) + ordering = StableOrderingFilter( + fields=( + ('user__username', 'username'), + ('id', 'id'), + ), + help_text='Order the results by username or id. Prefix with "-" to reverse. Defaults to id.', + ) + + class Meta: + model = CourseEnrollment + fields = [] + form = CourseGradeFilterForm diff --git a/lms/djangoapps/grades/rest_api/v2/pagination.py b/lms/djangoapps/grades/rest_api/v2/pagination.py new file mode 100644 index 000000000000..22c317e79693 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/pagination.py @@ -0,0 +1,52 @@ +"""Pagination for the Grades API v2 list endpoints.""" + +from edx_rest_framework_extensions.paginators import DefaultPagination + + +class GradePagination(DefaultPagination): + """ + The shared page-number paginator, with its response body described to the schema generator. + + The shared class builds the seven-field body but does not describe it, so + without this override the published schema lists four of the seven fields + callers actually receive. Remove the override once the shared class + describes its own body. + """ + + def get_paginated_response_schema(self, schema): + """Describe the paginated response body this paginator returns.""" + return { + 'type': 'object', + 'required': ['count', 'num_pages', 'current_page', 'start', 'next', 'previous', 'results'], + 'properties': { + 'count': { + 'type': 'integer', + 'description': 'Total number of results across every page.', + }, + 'num_pages': { + 'type': 'integer', + 'description': 'Total number of pages.', + }, + 'current_page': { + 'type': 'integer', + 'description': 'Number of the page in this response, counting from one.', + }, + 'start': { + 'type': 'integer', + 'description': 'Index of the first result on this page, counting from zero.', + }, + 'next': { + 'type': 'string', + 'format': 'uri', + 'nullable': True, + 'description': 'URL of the next page, or null on the last page.', + }, + 'previous': { + 'type': 'string', + 'format': 'uri', + 'nullable': True, + 'description': 'URL of the previous page, or null on the first page.', + }, + 'results': schema, + }, + } diff --git a/lms/djangoapps/grades/rest_api/v2/permissions.py b/lms/djangoapps/grades/rest_api/v2/permissions.py new file mode 100644 index 000000000000..f58d797ec94f --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/permissions.py @@ -0,0 +1,153 @@ +"""Permission classes for the Grades API v2.""" + +from edx_rest_framework_extensions.permissions import ( + JwtHasScope, + JwtRestrictedApplication, + NotJwtRestrictedApplication, +) +from rest_framework.exceptions import NotFound +from rest_framework.permissions import BasePermission, IsAdminUser, IsAuthenticated + +from common.djangoapps.student.auth import has_course_author_access, is_ccx_course +from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.grades.api import is_writable_gradebook_enabled +from lms.djangoapps.grades.grade_utils import are_grades_frozen +from lms.djangoapps.grades.rest_api.v1.gradebook_views import _has_ccx_gradebook_access +from lms.djangoapps.grades.rest_api.v2.errors import GradesFrozen, WritableGradebookDisabled +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +#: Endpoint access for course-grade reads: any authenticated caller, and a token +#: issued to a restricted application only when it carries the view's +#: ``required_scopes``. Which rows a caller then sees is decided by the +#: queryset scoping policy, not here. +COURSE_GRADE_READ_ACCESS = IsAuthenticated & ( + NotJwtRestrictedApplication | (JwtRestrictedApplication & JwtHasScope) +) + + +class HasGradeBreakdownAccess(BasePermission): + """ + Refuses the grade breakdown to anyone but global staff signed in as themselves. + + The breakdown carries per-subsection scores, including scores the course + hides from learners, so a learner may not read even their own, and a + restricted application token may not read it whatever its filters or user. + Requests that do not ask for the breakdown pass. The view reports whether + the request asks for it through ``breakdown_requested()``. + """ + + message = 'Only global staff may read the grade breakdown.' + + def has_permission(self, request, view): + """Return True unless the breakdown is requested by someone other than global staff.""" + if not view.breakdown_requested(): + return True + return ( + IsAdminUser().has_permission(request, view) + and NotJwtRestrictedApplication().has_permission(request, view) + ) + + +class HasGradebookAccess(BasePermission): + """ + Allows callers who may manage the gradebook of the course the request names. + + For a CCX course that is a CCX coach, a staff member or instructor of the + CCX, or global staff, provided custom courses are enabled for the platform + and the master course. For any other course it is a caller with author + access to the course. + + The view supplies the course through ``get_course_key()``. + """ + + message = 'You do not have access to the gradebook of this course.' + + def has_permission(self, request, view): + """Return True when the caller may manage the gradebook of the requested course.""" + course_key = view.get_course_key() + if is_ccx_course(course_key): + return _has_ccx_gradebook_access(request.user, course_key) + return has_course_author_access(request.user, course_key) + + +class HasCourseStaffAccess(BasePermission): + """ + Allows callers with staff access to the course the request names. + + That is global staff and the staff and instructors of the course or its + organization, unless they are viewing the course as a learner. The view + supplies the course through ``get_course_key()``. + """ + + def has_permission(self, request, view): + """Return True when the caller has staff access to the requested course.""" + return bool(has_access(request.user, 'staff', view.get_course_key())) + + +#: Endpoint access for reading how a course is graded: its staff, and anyone +#: who may manage its gradebook. +COURSE_GRADING_POLICY_READ_ACCESS = IsAuthenticated & (HasCourseStaffAccess | HasGradebookAccess) + + +class HasGradebookAccessUnlessMinimal(BasePermission): + """ + Requires gradebook access for any but the minimal view of a grading policy. + + Course staff may read the grade cutoffs and assignment types, which the + minimal view returns. The subsections, freeze state and bulk-management + setting of the full view are for those who may manage the gradebook, so + course staff who may not, such as the staff of a custom course while + custom courses are switched off, get the minimal view only. + """ + + message = HasGradebookAccess.message + + def has_permission(self, request, view): + """Return True for the minimal view, and otherwise when the caller may manage the gradebook.""" + if view.is_minimal_view_requested(request): + return True + return HasGradebookAccess().has_permission(request, view) + + +class CourseExists(BasePermission): + """ + Refuses, as not found, requests that name a course which does not exist. + + Place it after the access check, so a caller without access cannot use it + to learn whether a course exists. + """ + + def has_permission(self, request, view): + """Return True when the requested course exists.""" + if not CourseOverview.course_exists(view.get_course_key()): + raise NotFound() + return True + + +class WritableGradebookEnabled(BasePermission): + """ + Refuses requests for a course whose writable gradebook is switched off. + + Place it after the access check, so a caller without access cannot use it + to learn how the course is configured. + """ + + def has_permission(self, request, view): + """Return True when the writable gradebook is enabled for the requested course.""" + if not is_writable_gradebook_enabled(view.get_course_key()): + raise WritableGradebookDisabled() + return True + + +class GradesNotFrozen(BasePermission): + """ + Refuses requests to change the grades of a course whose grades are frozen. + + Place it last, after the access, existence and feature checks. + """ + + def has_permission(self, request, view): + """Return True when the grades of the requested course may still change.""" + if are_grades_frozen(view.get_course_key()): + raise GradesFrozen() + return True diff --git a/lms/djangoapps/grades/rest_api/v2/scoping.py b/lms/djangoapps/grades/rest_api/v2/scoping.py new file mode 100644 index 000000000000..d3304ee17ba1 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/scoping.py @@ -0,0 +1,57 @@ +"""Record-visibility policies for the Grades API v2 list endpoints.""" + +from edx_rest_framework_extensions.auth.jwt.authentication import is_jwt_authenticated +from edx_rest_framework_extensions.auth.jwt.decoder import decode_jwt_filters, decode_jwt_is_restricted + +CONTENT_ORG_FILTER = 'content_org' +USER_FILTER = 'user' +CURRENT_USER_FILTER_VALUE = 'me' + + +class CourseGradeScopingPolicy: + """ + Narrows course-grade rows to the enrollments the caller is allowed to read. + + A token issued to a restricted application sees only rows whose course + organization is named by one of the token's organization filters; a + restricted token carrying no organization filter sees nothing. A user + filter narrows those rows to one learner. As in the token checks the rest + of the platform applies, only the first user filter counts, and it is + compared with the learner's username in lower case, so a filter naming a + username with capitals matches nobody. Global staff see every row. Every + other caller sees their own enrollments only. + + The policy is built per request because the token filters it reads live on + the request, not on the user. + """ + + def __init__(self, request): + self.request = request + + def scope(self, queryset, subject): + """Return ``queryset`` narrowed to the rows ``subject`` may see.""" + if self._is_restricted_token(): + return self._apply_token_filters(queryset) + if subject.is_staff: + return queryset + return queryset.filter(user=subject) + + def _is_restricted_token(self): + """Return True when the request was authenticated by a restricted application token.""" + return is_jwt_authenticated(self.request) and decode_jwt_is_restricted(self.request.auth) + + def _apply_token_filters(self, queryset): + """Return ``queryset`` narrowed to the organizations and learner the token names.""" + token_filters = decode_jwt_filters(self.request.auth) + organizations = [value for kind, value in token_filters if kind == CONTENT_ORG_FILTER] + if not organizations: + return queryset.none() + queryset = queryset.filter(course__org__in=organizations) + username = next((value for kind, value in token_filters if kind == USER_FILTER), None) + if username is None: + return queryset + if username == CURRENT_USER_FILTER_VALUE: + username = self.request.user.username.lower() + if username != username.lower(): + return queryset.none() + return queryset.filter(user__username__iexact=username) diff --git a/lms/djangoapps/grades/rest_api/v2/serializers.py b/lms/djangoapps/grades/rest_api/v2/serializers.py new file mode 100644 index 000000000000..df574d8deb1a --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/serializers.py @@ -0,0 +1,552 @@ +"""Serializers for the Grades API v2.""" + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKey +from rest_framework import serializers +from rest_framework.settings import api_settings + +from common.djangoapps.student.models import CourseEnrollment + +MAX_OVERRIDES = 100 + + +# pylint: disable=abstract-method +class GradeSectionMarkSerializer(serializers.Serializer): + """A note the course grader attached to one entry of a grade breakdown.""" + + detail = serializers.CharField( + help_text='Explanation of the note, for example why the entry does not count towards the grade.', + ) + + class Meta: + ref_name = 'grade_v2.GradeSectionMark' + + +class CourseGradeSectionSerializer(serializers.Serializer): + """One entry of the breakdown the course grader produces for a learner's course grade.""" + + category = serializers.CharField( + help_text="Assignment type the entry belongs to, for example 'Homework'.", + ) + label = serializers.CharField( + help_text="Short label of the entry, for example 'HW 01' or 'HW Avg'.", + ) + detail = serializers.CharField( + help_text="Human-readable summary of the entry, for example 'Homework 1 - Ohms Law - 83% (5/6)'.", + ) + percent = serializers.FloatField( + help_text='Fraction of the entry the learner earned, between 0 and 1.', + ) + usage_key = serializers.CharField( + source='sequential_id', + allow_null=True, + help_text=( + 'Opaque key of the subsection the entry scores, or null for an entry that ' + 'summarises an assignment type or stands for a subsection not yet released.' + ), + ) + prominent = serializers.BooleanField( + required=False, + help_text='Present and true on the entry that summarises an assignment type.', + ) + mark = GradeSectionMarkSerializer( + required=False, + help_text='Present when the grader attached a note to the entry, such as the entry being dropped.', + ) + + class Meta: + ref_name = 'grade_v2.CourseGradeSection' + + +class CourseGradeSerializer(serializers.Serializer): + """A learner's overall grade in one course.""" + + username = serializers.CharField( + help_text='Username of the learner the grade belongs to.', + ) + course_key = serializers.CharField( + help_text='Opaque key of the course the grade belongs to.', + ) + email = serializers.CharField( + allow_blank=True, + help_text="Email address of the learner. Empty unless the learner is enrolled in the master's track.", + ) + passed = serializers.BooleanField( + help_text='Whether the learner currently meets the passing grade of the course.', + ) + percent = serializers.FloatField( + help_text='Fraction of the course the learner has earned, between 0 and 1.', + ) + letter_grade = serializers.CharField( + allow_null=True, + help_text='Letter grade the learner has earned, or null when no grade cutoff has been reached.', + ) + section_breakdown = CourseGradeSectionSerializer( + many=True, + required=False, + help_text="Breakdown of the grade by subsection and assignment type. Returned only for 'view=full'.", + ) + + class Meta: + ref_name = 'grade_v2.CourseGrade' + + +class CourseGradeQuerySerializer(serializers.Serializer): + """The representation a course-grade request may ask for.""" + + view = serializers.ChoiceField( + choices=['full'], + required=False, + help_text="Send 'full' to include the breakdown of each grade.", + ) + + class Meta: + ref_name = 'grade_v2.CourseGradeQuery' + + +class GradebookSectionSerializer(serializers.Serializer): + """One graded subsection's contribution to a learner's row in the gradebook.""" + + attempted = serializers.BooleanField( + help_text='Whether the learner has attempted the subsection or has a grade override on it.', + ) + category = serializers.CharField( + help_text="Assignment type of the subsection, for example 'Homework'.", + ) + label = serializers.CharField( + allow_null=True, + help_text="Short label of the subsection, for example 'HW 01', or null when the course grades no such type.", + ) + usage_key = serializers.CharField( + help_text='Opaque key of the subsection.', + ) + percent = serializers.FloatField( + help_text='Fraction of the subsection the learner earned, between 0 and 1.', + ) + score_earned = serializers.FloatField( + help_text='Points the learner earned on the graded content of the subsection; 0 until attempted.', + ) + score_possible = serializers.FloatField( + help_text='Points available on the graded content of the subsection; 0 until attempted.', + ) + subsection_name = serializers.CharField( + help_text='Display name of the subsection.', + ) + + class Meta: + ref_name = 'grade_v2.GradebookSection' + + +class GradebookEntrySerializer(serializers.Serializer): + """A learner's row in the gradebook of one course.""" + + username = serializers.CharField( + help_text='Username of the learner the row belongs to.', + ) + full_name = serializers.CharField( + required=False, + help_text="Full name of the learner. Present only for learners enrolled in the master's track.", + ) + email = serializers.CharField( + allow_blank=True, + required=False, + help_text=( + "Email address of the learner. Empty unless the learner is enrolled in the master's track. " + "Left out for 'view=minimal'." + ), + ) + external_user_key = serializers.CharField( + required=False, + help_text='Key the learner is known by in the program they enrolled through. Present only when there is one.', + ) + percent = serializers.FloatField( + help_text='Fraction of the course the learner has earned, between 0 and 1.', + ) + section_breakdown = GradebookSectionSerializer( + many=True, + required=False, + help_text="The learner's score on each graded subsection of the course. Left out for 'view=minimal'.", + ) + + class Meta: + ref_name = 'grade_v2.GradebookEntry' + + +class GradebookEntryQuerySerializer(serializers.Serializer): + """The representation a gradebook request may ask for.""" + + view = serializers.ChoiceField( + choices=['minimal'], + required=False, + help_text="Send 'minimal' to reduce each row to the learner's username and overall percent.", + ) + + class Meta: + ref_name = 'grade_v2.GradebookEntryQuery' + + +_OVERRIDE_VALUE_HELP = ( + 'Points to record in place of the computed {what}. Leave it out to keep the ' + 'computed value; send null to clear an earlier override of it.' +) + + +class SubsectionGradeOverrideRequestItemSerializer(serializers.Serializer): + """One learner's override of one subsection grade, as a client sends it.""" + + username = serializers.CharField( + help_text='Username of a learner enrolled in the course.', + ) + usage_key = serializers.CharField( + help_text='Opaque key of a subsection of the course.', + ) + earned_all_override = serializers.FloatField( + required=False, allow_null=True, + help_text=_OVERRIDE_VALUE_HELP.format(what='points earned on all content'), + ) + possible_all_override = serializers.FloatField( + required=False, allow_null=True, + help_text=_OVERRIDE_VALUE_HELP.format(what='points possible on all content'), + ) + earned_graded_override = serializers.FloatField( + required=False, allow_null=True, + help_text=_OVERRIDE_VALUE_HELP.format(what='points earned on graded content'), + ) + possible_graded_override = serializers.FloatField( + required=False, allow_null=True, + help_text=_OVERRIDE_VALUE_HELP.format(what='points possible on graded content'), + ) + comment = serializers.CharField( + required=False, allow_null=True, allow_blank=True, max_length=300, + help_text='Why the grade was overridden, kept with the history of the grade.', + ) + + class Meta: + ref_name = 'grade_v2.SubsectionGradeOverrideRequestItem' + + def validate_usage_key(self, value): + """Return the parsed usage key, refusing one that is malformed or of another course.""" + try: + usage_key = UsageKey.from_string(value) + except InvalidKeyError as exc: + raise serializers.ValidationError('Not a valid usage key.') from exc + if usage_key.deprecated: + raise serializers.ValidationError('Not a valid usage key.') + in_course = usage_key.course_key == self.context['course_key'] + if not in_course or self.context['course'].get_child(usage_key) is None: + raise serializers.ValidationError('No such subsection in this course.') + return usage_key + + def validate_username(self, value): + """Return the enrollment of the learner named, refusing a learner not enrolled in the course.""" + enrollment = CourseEnrollment.objects.select_related('user').filter( + user__username=value, course_id=self.context['course_key'], + ).first() + if enrollment is None: + raise serializers.ValidationError('No learner with this username is enrolled in this course.') + return enrollment + + +class SubsectionGradeOverrideRequestSerializer(serializers.Serializer): + """A batch of subsection grade overrides, applied together or not at all.""" + + overrides = SubsectionGradeOverrideRequestItemSerializer( + many=True, + allow_empty=False, + max_length=MAX_OVERRIDES, + help_text=( + f'The overrides to record, at most {MAX_OVERRIDES}. When any of them is not valid, ' + "none is recorded, and each error is keyed by the path of the member at fault, " + "such as 'overrides[1].username' " + 'for the second item, counting from zero.' + ), + ) + + class Meta: + ref_name = 'grade_v2.SubsectionGradeOverrideRequest' + + def errors_by_path(self): + """ + Return the validation errors keyed by the path of the member at fault. + + Errors on one item of the batch are keyed like ``overrides[1].username``, + so a client can tell which items to fix, and every value is a list of + messages, as in any other validation error. + """ + return dict(_error_paths(self.errors)) + + +def _error_paths(errors, path=''): + """Yield ``(path, messages)`` for every leaf of a nested validation error.""" + if isinstance(errors, dict): + for key, value in errors.items(): + if key == api_settings.NON_FIELD_ERRORS_KEY and path: + yield from _error_paths(value, path) + else: + yield from _error_paths(value, f'{path}.{key}' if path else key) + elif errors and all(isinstance(item, (dict, list)) for item in errors): + for index, item in enumerate(errors): + if item: + yield from _error_paths(item, f'{path}[{index}]') + else: + yield path, [str(message) for message in errors] + + +class SubsectionGradeOverrideSerializer(serializers.Serializer): + """An override recorded on one learner's grade for one subsection.""" + + username = serializers.CharField( + help_text='Username of the learner whose grade was overridden.', + ) + usage_key = serializers.CharField( + help_text='Opaque key of the subsection.', + ) + earned_all_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as earned on all content, or null when not overridden.', + ) + possible_all_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as possible on all content, or null when not overridden.', + ) + earned_graded_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as earned on graded content, or null when not overridden.', + ) + possible_graded_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as possible on graded content, or null when not overridden.', + ) + comment = serializers.CharField( + allow_null=True, + help_text='Why the grade was overridden, or null when no reason was given.', + ) + + class Meta: + ref_name = 'grade_v2.SubsectionGradeOverride' + + +class SubsectionGradeOverrideListSerializer(serializers.Serializer): + """The overrides a batch recorded, in the order they were sent.""" + + overrides = SubsectionGradeOverrideSerializer( + many=True, help_text='The overrides recorded, in the order they were sent.', + ) + + class Meta: + ref_name = 'grade_v2.SubsectionGradeOverrideList' + + +class SubsectionScoreSerializer(serializers.Serializer): + """The points a learner has earned on a subsection, as computed from their answers.""" + + earned_all = serializers.FloatField(help_text='Points earned on all content of the subsection.') + possible_all = serializers.FloatField(help_text='Points available on all content of the subsection.') + earned_graded = serializers.FloatField(help_text='Points earned on the graded content of the subsection.') + possible_graded = serializers.FloatField(help_text='Points available on the graded content of the subsection.') + + class Meta: + ref_name = 'grade_v2.SubsectionScore' + + +class SubsectionGradeOverrideValuesSerializer(serializers.Serializer): + """The values an override records in place of the computed points.""" + + earned_all_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as earned on all content, or null when not overridden.', + ) + possible_all_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as possible on all content, or null when not overridden.', + ) + earned_graded_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as earned on graded content, or null when not overridden.', + ) + possible_graded_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as possible on graded content, or null when not overridden.', + ) + + class Meta: + ref_name = 'grade_v2.SubsectionGradeOverrideValues' + + +class SubsectionGradeSerializer(serializers.Serializer): + """One learner's grade on one subsection, and the override recorded on it.""" + + username = serializers.CharField(help_text='Username of the learner the grade belongs to.') + usage_key = serializers.CharField(help_text='Opaque key of the subsection.') + course_key = serializers.CharField(help_text='Opaque key of the course the subsection belongs to.') + original_grade = SubsectionScoreSerializer( + help_text=( + 'The points computed from the learner\'s answers. For a learner who has not attempted ' + 'the subsection, the points available and none earned.' + ), + ) + override = SubsectionGradeOverrideValuesSerializer( + allow_null=True, help_text='The override recorded on the grade, or null when there is none.', + ) + + class Meta: + ref_name = 'grade_v2.SubsectionGrade' + + +class SubsectionGradeOverrideHistoryRecordSerializer(serializers.Serializer): + """One change to the override recorded on a subsection grade.""" + + history_id = serializers.IntegerField(help_text='Identifier of this history record.') + history_date = serializers.DateTimeField(help_text='When the change was made.') + history_type = serializers.CharField( + help_text="Kind of change: '+' the override was created, '~' changed, '-' removed.", + ) + history_user = serializers.CharField( + source='history_user.username', allow_null=True, + help_text='Username of whoever made the change, or null when it was made by the system.', + ) + earned_all_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as earned on all content, or null when not overridden.', + ) + possible_all_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as possible on all content, or null when not overridden.', + ) + earned_graded_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as earned on graded content, or null when not overridden.', + ) + possible_graded_override = serializers.FloatField( + allow_null=True, help_text='Points recorded as possible on graded content, or null when not overridden.', + ) + override_reason = serializers.CharField( + allow_null=True, help_text='Why the grade was overridden, or null when no reason was given.', + ) + system = serializers.CharField( + allow_null=True, help_text="Feature the override was made through, for example 'GRADEBOOK'.", + ) + + class Meta: + ref_name = 'grade_v2.SubsectionGradeOverrideHistoryRecord' + + +class AssignmentTypeSerializer(serializers.Serializer): + """One assignment type a course grades against.""" + + type = serializers.CharField(help_text="Name of the assignment type, for example 'Homework'.") + short_label = serializers.CharField( + allow_null=True, + help_text="Prefix of the short labels of assignments of this type, for example 'HW', or null.", + ) + min_count = serializers.IntegerField(help_text='Number of assignments of this type the grade expects.') + drop_count = serializers.IntegerField(help_text='Number of lowest-scored assignments of this type left out.') + weight = serializers.FloatField(help_text='Share of the course grade this type carries, between 0 and 1.') + + class Meta: + ref_name = 'grade_v2.AssignmentType' + + +class GradingSubsectionSerializer(serializers.Serializer): + """One subsection of a course that learners can see.""" + + usage_key = serializers.CharField(help_text='Opaque key of the subsection.') + display_name = serializers.CharField( + allow_null=True, help_text='Display name of the subsection, or null when the course gives it none.', + ) + graded = serializers.BooleanField(help_text='Whether the subsection counts towards the grade.') + assignment_type = serializers.CharField( + allow_null=True, help_text='Assignment type of the subsection, or null when it has none.', + ) + short_label = serializers.CharField( + allow_null=True, help_text="Short label of a graded subsection, for example 'HW 01', else null.", + ) + + class Meta: + ref_name = 'grade_v2.GradingSubsection' + + +class CourseGradingPolicySerializer(serializers.Serializer): + """How one course is graded.""" + + grade_cutoffs = serializers.DictField( + child=serializers.FloatField(), + help_text="Lowest fraction of the course, between 0 and 1, that earns each letter grade, keyed by letter.", + ) + assignment_types = AssignmentTypeSerializer( + many=True, help_text='The assignment types the course grades against, in the order the course lists them.', + ) + subsections = GradingSubsectionSerializer( + many=True, required=False, + help_text="The subsections learners can see, in course order. Left out for 'view=minimal'.", + ) + grades_frozen = serializers.BooleanField( + required=False, + help_text=( + "Whether the course ended long enough ago that its grades may no longer change. " + "Left out for 'view=minimal'." + ), + ) + bulk_management_enabled = serializers.BooleanField( + required=False, + help_text="Whether bulk grade management is offered for the course. Left out for 'view=minimal'.", + ) + + class Meta: + ref_name = 'grade_v2.CourseGradingPolicy' + + +class CourseGradingPolicyQuerySerializer(serializers.Serializer): + """The options a grading-policy request may send.""" + + view = serializers.ChoiceField( + choices=['minimal'], required=False, + help_text="Send 'minimal' for the grade cutoffs and assignment types only.", + ) + graded_only = serializers.BooleanField( + default=False, help_text='Send true to leave the subsections that carry no grade out.', + ) + + class Meta: + ref_name = 'grade_v2.CourseGradingPolicyQuery' + + +class SubmissionSerializer(serializers.Serializer): + """One recorded state of a learner's answer to a problem.""" + + state = serializers.JSONField( + allow_null=True, help_text='The stored state of the problem for the learner at the time, or null.', + ) + grade = serializers.FloatField(allow_null=True, help_text='Points earned at the time, or null when not scored.') + max_grade = serializers.FloatField( + allow_null=True, help_text='Points available at the time, or null when not scored.', + ) + + class Meta: + ref_name = 'grade_v2.Submission' + + +class ProblemSubmissionHistorySerializer(serializers.Serializer): + """The recorded submissions of one learner to one problem.""" + + usage_key = serializers.CharField(help_text='Opaque key of the problem.') + display_name = serializers.CharField(allow_null=True, help_text='Display name of the problem, or null.') + submissions = SubmissionSerializer(many=True, help_text='The recorded states, newest first.') + data = serializers.CharField( + required=False, help_text="The definition of the problem. Returned only for 'view=full'.", + ) + + class Meta: + ref_name = 'grade_v2.ProblemSubmissionHistory' + + +class SubmissionHistorySerializer(serializers.Serializer): + """One learner's submission history across the scored problems of a course.""" + + username = serializers.CharField(help_text='Username of the learner.') + problems = ProblemSubmissionHistorySerializer( + many=True, help_text='The problems the learner has a recorded submission to, in course order.', + ) + + class Meta: + ref_name = 'grade_v2.SubmissionHistory' + + +class SubmissionHistoryQuerySerializer(serializers.Serializer): + """The representation a submission-history request may ask for.""" + + view = serializers.ChoiceField( + choices=['full'], required=False, help_text="Send 'full' to include the definition of each problem.", + ) + + class Meta: + ref_name = 'grade_v2.SubmissionHistoryQuery' diff --git a/lms/djangoapps/grades/rest_api/v2/services.py b/lms/djangoapps/grades/rest_api/v2/services.py new file mode 100644 index 000000000000..3de467f73317 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/services.py @@ -0,0 +1,574 @@ +"""Grade reads shared by the Grades API v2 views.""" + +import json +import logging +from collections import defaultdict +from contextlib import contextmanager +from datetime import timedelta + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.http import Http404 +from django.utils import timezone +from rest_framework.exceptions import NotFound + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment +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 +from lms.djangoapps.courseware.exceptions import CourseRunNotFound +from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule +from lms.djangoapps.grades.api import ( + CourseGradeFactory, + clear_prefetched_course_and_subsection_grades, + clear_prefetched_course_grades, + gradebook_bulk_management_enabled, + prefetch_course_and_subsection_grades, + prefetch_course_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.config.waffle import ENFORCE_FREEZE_GRADE_AFTER_COURSE_END +from lms.djangoapps.grades.course_data import CourseData +from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride +from lms.djangoapps.grades.rest_api.v1.views import SubmissionHistoryView +from lms.djangoapps.grades.rest_api.v2.errors import SubsectionUnavailable +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 fetch_program_course_enrollments_by_students +from openedx.core.djangoapps.content.block_structure.exceptions import UsageKeyNotInBlockStructure +from openedx.core.djangoapps.course_groups import cohorts +from openedx.core.lib.courses import get_course_by_id +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order +from xmodule.util.misc import get_default_short_labeler # pylint: disable=wrong-import-order + +log = logging.getLogger(__name__) + + +#: The grade values an override may replace. +OVERRIDE_FIELDS = ( + 'earned_all_override', + 'possible_all_override', + 'earned_graded_override', + 'possible_graded_override', +) + + +def course_content(course_key, depth=0): + """ + Return the course block of ``course_key``, loaded ``depth`` levels deep. + + Raises NotFound when the course has no content, which happens when its + overview outlives it. + """ + try: + return get_course_by_id(course_key, depth=depth) + except Http404 as exc: + log.info('Course %s has an overview but no content.', course_key) + raise NotFound() from exc + + +def course_grade_rows(enrollments, with_section_breakdown=False): + """ + Return one course-grade row per enrollment, in the order the enrollments were given. + + Enrollments whose grade cannot be computed are logged and left out, so a + page may hold fewer rows than it has enrollments. + + Arguments: + enrollments: CourseEnrollment objects, which may span several courses. + with_section_breakdown (bool): also include the course grader's + breakdown of each grade. + """ + enrollments = list(enrollments) + rows_by_enrollment = {} + for course_key, course_enrollments in _group_by_course(enrollments).items(): + users = [enrollment.user for enrollment in course_enrollments] + grades = {} + with _prefetched_course_grades(course_key, users): + for user, course_grade, error in CourseGradeFactory().iter(users, course_key=course_key): + if error: + log.warning( + 'Leaving user %s out of the grades of course %s: grade unavailable.', user.id, course_key, + ) + continue + grades[user.id] = course_grade + for enrollment in course_enrollments: + course_grade = grades.get(enrollment.user_id) + if course_grade is not None: + rows_by_enrollment[enrollment.id] = _course_grade_row(enrollment, course_grade, with_section_breakdown) + return [rows_by_enrollment[enrollment.id] for enrollment in enrollments if enrollment.id in rows_by_enrollment] + + +def course_grade_row(enrollment, with_section_breakdown=False): + """ + Return the course-grade row of one enrollment. + + Unlike ``course_grade_rows``, a grade that cannot be computed raises, since + the caller asked for this grade and nothing else. + """ + course_grade = CourseGradeFactory().read(enrollment.user, course_key=enrollment.course_id) + return _course_grade_row(enrollment, course_grade, with_section_breakdown) + + +def _course_grade_row(enrollment, course_grade, with_section_breakdown): + """Return the row for one enrollment and its computed grade.""" + row = { + 'username': enrollment.user.username, + 'course_key': str(enrollment.course_id), + 'email': visible_email(enrollment), + 'passed': course_grade.passed, + 'percent': course_grade.percent, + 'letter_grade': course_grade.letter_grade, + } + if with_section_breakdown: + row['section_breakdown'] = course_grade.summary['section_breakdown'] + return row + + +def gradebook_entry_rows(enrollments, course_key): + """ + Return one gradebook row per enrollment in ``course_key``, in the order given. + + Enrollments whose grade cannot be computed are logged and left out, so a + page may hold fewer rows than it has enrollments. + """ + enrollments = list(enrollments) + if not enrollments: + return [] + structure = _GradebookStructure(course_key) + users = [enrollment.user for enrollment in enrollments] + external_keys = _external_user_keys(users, course_key) + grades = {} + with _prefetched_gradebook(course_key, users): + for user, course_grade, error in CourseGradeFactory().iter( + users, course_key=course_key, collected_block_structure=structure.collected, + ): + if error: + log.warning( + 'Leaving user %s out of the gradebook of course %s: grade unavailable.', user.id, course_key, + ) + continue + grades[user.id] = course_grade + return [ + _gradebook_entry(enrollment, structure, grades[enrollment.user_id], external_keys.get(enrollment.user_id)) + for enrollment in enrollments if enrollment.user_id in grades + ] + + +def gradebook_entry_row(enrollment, course_key): + """ + Return the gradebook row of one enrollment in ``course_key``. + + A grade that cannot be computed raises, since the caller asked for this row + and nothing else. + """ + structure = _GradebookStructure(course_key) + course_grade = CourseGradeFactory().read( + enrollment.user, structure.course, collected_block_structure=structure.collected, + ) + external_key = _external_user_keys([enrollment.user], course_key).get(enrollment.user_id) + return _gradebook_entry(enrollment, structure, course_grade, external_key) + + +class _GradebookStructure: + """ + The parts of a course's content every gradebook row is built from. + + The structure is read once for the course, not once per learner: reading a + learner's own view of the course is far more expensive, and the gradebook + shows the same subsections for everyone. + """ + + def __init__(self, course_key): + self.course = course_content(course_key, depth=None) + self.collected = CourseData(user=None, course=self.course).collected_structure + self.graded_subsections = list(grades_context.graded_subsections_for_course(self.collected)) + + +def _gradebook_entry(enrollment, structure, course_grade, external_user_key): + """Return the gradebook row for one enrollment and its computed grade.""" + user = enrollment.user + entry = { + 'username': user.username, + 'email': visible_email(enrollment), + 'percent': course_grade.percent, + 'section_breakdown': _gradebook_sections(structure, course_grade), + } + if enrollment.mode == CourseMode.MASTERS: + entry['full_name'] = user.profile.name + if external_user_key: + entry['external_user_key'] = external_user_key + return entry + + +def _gradebook_sections(structure, course_grade): + """Return the learner's score on each graded subsection of the course.""" + # The labeler numbers the subsections of each type as it is called, so + # every row needs a fresh one. + labeler = get_default_short_labeler(structure.course) + sections = [] + for subsection in structure.graded_subsections: + subsection_grade = course_grade.subsection_grade(subsection.location) + # Reading the scores of a subsection the learner has not attempted + # would mean reading the learner's own view of the course, so an + # unattempted subsection reports zero scores instead. + attempted = bool(subsection_grade.attempted_graded or subsection_grade.override) + sections.append({ + 'attempted': attempted, + 'category': subsection_grade.format, + 'label': labeler(subsection_grade.format), + 'usage_key': str(subsection_grade.location), + 'percent': subsection_grade.percent_graded, + 'score_earned': subsection_grade.graded_total.earned if attempted else 0, + 'score_possible': subsection_grade.graded_total.possible if attempted else 0, + 'subsection_name': subsection_grade.display_name, + }) + return sections + + +def _external_user_keys(users, course_key): + """ + Return ``{user id: program key}`` for the learners enrolled in ``course_key`` through a program. + + Where a learner has several program enrollments in the course, the one + listed first by status and then most recently modified wins. + """ + program_course_enrollments = fetch_program_course_enrollments_by_students( + users=users, course_keys=[course_key], + ).select_related('program_enrollment').order_by('status', '-modified') + keys = {} + for program_course_enrollment in program_course_enrollments: + program_enrollment = program_course_enrollment.program_enrollment + keys.setdefault(program_enrollment.user_id, program_enrollment.external_user_key) + return keys + + +def visible_email(enrollment): + """Return the learner's email address, which only learners on the master's track expose.""" + return enrollment.user.email if enrollment.mode == CourseMode.MASTERS else '' + + +def _group_by_course(enrollments): + """Return ``{course key: [enrollment, ...]}``, keeping the given order within each course.""" + grouped = defaultdict(list) + for enrollment in enrollments: + grouped[enrollment.course_id].append(enrollment) + return grouped + + +@contextmanager +def _prefetched_course_grades(course_key, users): + """Prefetch the course grades of ``users`` in ``course_key`` for the duration of the block.""" + prefetch_course_grades(course_key, users) + try: + yield + finally: + clear_prefetched_course_grades(course_key) + + +@contextmanager +def _prefetched_gradebook(course_key, users): + """Prefetch the grades, enrollment states, cohorts and roles of ``users`` for the duration of the block.""" + 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 record_subsection_grade_overrides(requesting_user, course, items): + """ + Record the subsection grade overrides in ``items``, then recompute the grades they change. + + The overrides are stored in one transaction, so either every one of them is + recorded or none is. The grades they change are recomputed once all of them + are stored. Returns one row per override, in the order given. + + Arguments: + requesting_user: the user recording the overrides. + course: the course block, loaded to full depth. + items: validated override items, whose ``username`` holds the learner's + enrollment and whose ``usage_key`` holds the parsed subsection key. + """ + recorded = [] + with transaction.atomic(): + for item in items: + learner = item['username'].user + grade = _stored_subsection_grade(learner, course, item['usage_key']) + override = PersistentSubsectionGradeOverride.update_or_create_override( + requesting_user=requesting_user, + subsection_grade_model=grade, + feature=grades_constants.GradeOverrideFeatureEnum.gradebook, + system=grades_constants.GradeOverrideFeatureEnum.gradebook, + **{name: item[name] for name in (*OVERRIDE_FIELDS, 'comment') if name in item}, + ) + recorded.append((learner, item['usage_key'], grade, override)) + for learner, _, grade, override in recorded: + _recalculate_overridden_grade(grade, override) + log.info( + 'Grades: Bulk_Update, UpdatedByUser: %s, User: %s, Usage: %s, Grade: %s, GradeOverride: %s, Success: %s', + requesting_user.id, learner.id, grade.usage_key, grade, override, True, + ) + return [ + { + 'username': learner.username, + 'usage_key': str(usage_key), + **{name: getattr(override, name) for name in OVERRIDE_FIELDS}, + 'comment': override.override_reason, + } + for learner, usage_key, _, override in recorded + ] + + +def _stored_subsection_grade(learner, course, usage_key): + """Return the stored grade of ``learner`` on the subsection, storing a computed one when there is none.""" + try: + return PersistentSubsectionGrade.objects.get( + user_id=learner.id, course_id=usage_key.course_key, usage_key=usage_key, + ) + except PersistentSubsectionGrade.DoesNotExist: + subsection = course.get_child(usage_key) + structure = CourseData(learner, course=course).structure + return CreateSubsectionGrade(subsection, structure, {}, {}).update_or_create_model( + learner, force_update_subsections=True, + ) + + +def _recalculate_overridden_grade(grade, override): + """Recompute the subsection and course grades an override changed, and announce the change.""" + set_event_transaction_type(grades_events.SUBSECTION_GRADE_CALCULATED) + create_new_event_transaction_id() + recalculate_subsection_grade_v3.apply( + kwargs={ + 'user_id': grade.user_id, + 'anonymous_user_id': None, + 'course_id': str(grade.course_id), + 'usage_id': str(grade.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, + }, + ) + grades_events.subsection_grade_calculated(grade) + + +def enrolled_learner(username, course_key): + """ + Return the learner named ``username`` if they hold an enrollment in ``course_key``, active or not. + + Raises NotFound otherwise, so an address naming a learner outside the course + does not reveal whether the account exists. + """ + enrollment = CourseEnrollment.objects.select_related('user').filter( + user__username=username, course_id=course_key, + ).first() + if enrollment is None: + raise NotFound() + return enrollment.user + + +def read_subsection_grade(learner, usage_key): + """ + Return ``learner``'s grade on the subsection ``usage_key``, and the override recorded on it. + + A stored grade is returned as stored. For a learner with no stored grade, + the grade is computed from their view of the course without being stored. + Raises SubsectionUnavailable when that view does not include the + subsection, and NotFound when the course has no such block. + """ + try: + stored = PersistentSubsectionGrade.read_grade(learner.id, usage_key) + except PersistentSubsectionGrade.DoesNotExist: + scores, override = _computed_subsection_scores(learner, usage_key), None + else: + scores = {name: getattr(stored, name) for name in SUBSECTION_SCORE_FIELDS} + try: + override = stored.override + except ObjectDoesNotExist: + override = None + return { + 'username': learner.username, + 'usage_key': str(usage_key), + 'course_key': str(usage_key.course_key), + 'original_grade': scores, + 'override': override, + } + + +#: The points a subsection grade records. +SUBSECTION_SCORE_FIELDS = ('earned_all', 'possible_all', 'earned_graded', 'possible_graded') + + +def _computed_subsection_scores(learner, usage_key): + """Return the points ``learner`` has earned on the subsection, computed without storing them.""" + try: + structure = get_course_blocks(learner, usage_key) + except (ItemNotFoundError, UsageKeyNotInBlockStructure) as exc: + log.info('No block %s to grade for user %s.', usage_key, learner.id) + raise NotFound() from exc + if usage_key not in structure: + raise SubsectionUnavailable() + grade = SubsectionGradeFactory(learner, course_structure=structure).create( + structure[usage_key], read_only=True, force_calculate=True, + ) + return { + 'earned_all': grade.all_total.earned, + 'possible_all': grade.all_total.possible, + 'earned_graded': grade.graded_total.earned, + 'possible_graded': grade.graded_total.possible, + } + + +def course_grading_policy(course_key, with_subsections=True, graded_only=False): + """ + Return how ``course_key`` is graded. + + Arguments: + with_subsections (bool): also describe the subsections learners can see, + whether grades are frozen, and whether bulk grade management is + offered. Without them the course content is read one level deep only. + graded_only (bool): leave the subsections that carry no grade out. + """ + course = course_content(course_key, depth=2 if with_subsections else 0) + policy = { + 'grade_cutoffs': course.grade_cutoffs, + 'assignment_types': [ + { + 'type': assignment_type, + 'short_label': grader.short_label, + 'min_count': grader.min_count, + 'drop_count': grader.drop_count, + 'weight': weight, + } + for grader, assignment_type, weight in course.grader.subgraders + ], + } + if with_subsections: + policy['subsections'] = _grading_subsections(course, graded_only) + policy['grades_frozen'] = grades_frozen(course_key, course.end) + policy['bulk_management_enabled'] = _bulk_management_enabled(course_key) + return policy + + +def grades_frozen(course_key, course_end): + """ + Return whether the grades of ``course_key`` are frozen, given the course's end date. + + Grades freeze a configured number of days after the course ends, where + freezing is switched on for the course. The end date is passed in rather + than looked up because the shared lookup creates a missing course overview, + and a read must not. + """ + if not ENFORCE_FREEZE_GRADE_AFTER_COURSE_END.is_enabled(course_key) or not course_end: + return False + return timezone.now() > course_end + timedelta(settings.GRADEBOOK_FREEZE_DAYS) + + +def _grading_subsections(course, graded_only): + """Return one entry per subsection learners can see, in course order.""" + labeler = get_default_short_labeler(course) + subsections = [] + for section in _visible(course.get_children()): + for subsection in _visible(section.get_children()): + if graded_only and not subsection.graded: + continue + subsections.append({ + 'usage_key': str(subsection.location), + 'display_name': subsection.display_name, + 'graded': subsection.graded, + 'assignment_type': subsection.format, + 'short_label': labeler(subsection.format) if subsection.graded else None, + }) + return subsections + + +def _visible(blocks): + """Return the blocks that are neither staff-only nor hidden from the course outline.""" + return [block for block in blocks if not block.visible_to_staff_only and not block.hide_from_toc] + + +def _bulk_management_enabled(course_key): + """Return whether bulk grade management is offered: where it is switched on, or the course has a master's track.""" + if gradebook_bulk_management_enabled(course_key): + return True + return CourseMode.objects.filter(course_id=course_key, mode_slug=CourseMode.MASTERS).exists() + + +def submission_history_rows(enrollments, course_key, with_data=False): + """ + Return one submission-history row per enrollment in ``course_key``, in the order given. + + Each row lists the scored problems of the course the learner has a recorded + submission to. The submissions of the whole page are read together, so the + number of queries does not grow with the page. + + Arguments: + with_data (bool): also include the definition of each problem. + + Raises NotFound when the course has no content. + """ + enrollments = list(enrollments) + try: + course = get_course(course_key, depth=4) + except CourseRunNotFound as exc: + raise NotFound() from exc + problems = SubmissionHistoryView.get_problem_blocks(course) + submissions = _submissions_by_learner_and_problem(enrollments, course_key, problems) + rows = [] + for enrollment in enrollments: + row = {'username': enrollment.user.username, 'problems': []} + for problem in problems: + history = submissions.get((enrollment.user_id, problem.location)) + if not history: + continue + entry = { + 'usage_key': str(problem.scope_ids.usage_id), + 'display_name': problem.display_name, + 'submissions': history, + } + if with_data: + entry['data'] = problem.data + row['problems'].append(entry) + rows.append(row) + return rows + + +def _submissions_by_learner_and_problem(enrollments, course_key, problems): + """Return ``{(user id, problem location): [submission, ...]}``, newest first within each history table.""" + if not enrollments or not problems: + return {} + modules = list(StudentModule.objects.filter( + course_id=course_key, + student_id__in=[enrollment.user_id for enrollment in enrollments], + module_state_key__in=[problem.location for problem in problems], + )) + if not modules: + return {} + module_keys = {module.id: (module.student_id, module.module_state_key) for module in modules} + submissions = defaultdict(list) + for entry in BaseStudentModuleHistory.get_history(modules): + submissions[module_keys[entry.student_module_id]].append({ + 'state': json.loads(entry.state) if entry.state is not None else None, + 'grade': entry.grade, + 'max_grade': entry.max_grade, + }) + return submissions diff --git a/lms/djangoapps/grades/rest_api/v2/tests/__init__.py b/lms/djangoapps/grades/rest_api/v2/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/grades/rest_api/v2/tests/mixins.py b/lms/djangoapps/grades/rest_api/v2/tests/mixins.py new file mode 100644 index 000000000000..ff4c4b0cb2f1 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/mixins.py @@ -0,0 +1,307 @@ +"""Helpers shared by the Grades API v2 tests.""" + +import json +import re +from contextlib import contextmanager +from types import SimpleNamespace +from unittest import mock +from urllib.parse import urlencode + +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from openedx_authz.models.engine import PolicyCacheControl +from openedx_events.learning.signals import COHORT_MEMBERSHIP_CHANGED + +import openedx.core.djangoapps.content.block_structure.api as block_structure_api +from common.djangoapps.student.models import CourseEnrollment, anonymous_id_for_user +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.grades.models import ( + PersistentCourseGrade, + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, +) +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content.course_overviews.signals import IMPORT_COURSE_DETAILS +from openedx.core.djangoapps.course_groups import cohorts +from openedx.core.djangoapps.course_groups.models import CohortMembership +from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts +from openedx.core.djangoapps.oauth_dispatch.jwt import _create_jwt +from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED +from xmodule.modulestore import ModuleStoreEnum +from xmodule.partitions.partitions import Group, UserPartition + +GRADE_READ_SCOPE = 'grades:read' + +#: The members every paginated body carries. +PAGE_FIELDS = {'count', 'num_pages', 'current_page', 'start', 'next', 'previous', 'results'} + +_WRITE_STATEMENTS = ('INSERT', 'UPDATE', 'DELETE', 'REPLACE') +_WRITTEN_TABLE = re.compile( + r'^\s*(?:INSERT(?:\s+OR\s+\w+|\s+IGNORE)?\s+INTO|UPDATE|DELETE\s+FROM|REPLACE\s+INTO)\s+[`"]?(\w+)', + re.IGNORECASE, +) +_SESSION_WRITE = re.compile(r'^\s*(INSERT INTO|UPDATE|DELETE FROM)\s+[`"]?django_session[`"]?\s', re.IGNORECASE) + +#: Tables that hold grade and enrollment state a read must leave alone. +DOMAIN_TABLES = ( + CourseEnrollment, + CourseOverview, + PersistentCourseGrade, + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, +) + + +def with_query(path, **query): + """Return ``path`` with ``query`` appended when there is anything to append.""" + return f'{path}?{urlencode(query, doseq=True)}' if query else path + + +def course_grade_list_url(**query): + """Return the address of the course-grade collection.""" + return with_query(reverse('grade_v2:course_grade_list'), **query) + + +def course_grade_detail_url(username, course_key, **query): + """Return the address of one learner's grade in one course.""" + url = reverse('grade_v2:course_grade_detail', kwargs={'username': username, 'course_key': course_key}) + return with_query(url, **query) + + +def gradebook_entry_list_url(course_key, **query): + """Return the address of the gradebook of one course.""" + return with_query(reverse('grade_v2:gradebook_entry_list', kwargs={'course_key': course_key}), **query) + + +def gradebook_entry_detail_url(course_key, username, **query): + """Return the address of one learner's row in the gradebook of one course.""" + url = reverse('grade_v2:gradebook_entry_detail', kwargs={'course_key': course_key, 'username': username}) + return with_query(url, **query) + + +def subsection_grade_override_list_url(course_key): + """Return the address that records subsection grade overrides in one course.""" + return reverse('grade_v2:subsection_grade_override_list', kwargs={'course_key': course_key}) + + +def subsection_grade_detail_url(username, usage_key, **query): + """Return the address of one learner's grade on one subsection.""" + url = reverse('grade_v2:subsection_grade_detail', kwargs={'username': username, 'usage_key': usage_key}) + return with_query(url, **query) + + +def subsection_grade_override_history_url(username, usage_key, **query): + """Return the address of the override history of one learner's grade on one subsection.""" + url = reverse( + 'grade_v2:subsection_grade_override_history_list', + kwargs={'username': username, 'usage_key': usage_key}, + ) + return with_query(url, **query) + + +def course_grading_policy_url(course_key, **query): + """Return the address of the grading policy of one course.""" + return with_query(reverse('grade_v2:course_grading_policy', kwargs={'course_key': course_key}), **query) + + +def submission_history_list_url(course_key, **query): + """Return the address of the submission histories of one course.""" + return with_query(reverse('grade_v2:submission_history_list', kwargs={'course_key': course_key}), **query) + + +def usernames_in(response): + """Return the username of every row of a paginated body, in the order returned.""" + return [row['username'] for row in response.json()['results']] + + +def token_header(user, scopes=(GRADE_READ_SCOPE,), is_restricted=False, filters=()): + """ + Return the request headers carrying a JWT issued to ``user``. + + Arguments: + scopes: the scopes the token carries. + is_restricted: whether the token was issued to a restricted application, + whose scopes and filters are enforced. + filters: the token's filters, as ``':'`` strings. + """ + token = _create_jwt(user, scopes=list(scopes), is_restricted=is_restricted, filters=list(filters)) + return {'HTTP_AUTHORIZATION': f'JWT {token}'} + + +def warm_read_caches(course_key): + """ + Fill the stores a grade read populates on first use, for ``course_key``. + + Both are caches, not grade or enrollment state, and both exist on any site + that has served a request before, but either would hide a real write from + the read-only assertions below: + + * The collected block structure of the course. Any read that walks the + course content stores it on a miss, including the default course-grade + list, not only the views that return a per-subsection breakdown. + * The one policy-version row of the authorization engine + (``PolicyCacheControl``). The first permission check after start-up + creates it with ``get_or_create``. It holds only a version number the + engine compares to decide when to reload its cached policies, and no + receiver listens for its save. + """ + block_structure_api.update_course_in_cache(course_key) + PolicyCacheControl.get() + + +def warm_cohort_settings(course_key): + """ + Copy the cohort settings of ``course_key`` into the database. + + The first cohort-aware read of a course copies its cohort settings from the + course content into the database. The gradebook reads cohorts, so on a + course that no read has touched yet it performs that copy; see + ``GradebookColdCohortSettingsTest``. + """ + cohorts.is_course_cohorted(course_key) + + +def warm_anonymous_id(user, course_key): + """ + Store the anonymous id of ``user`` in ``course_key``. + + Computing a grade the learner has no stored grade for reads their + submissions, which are keyed by the learner's anonymous id in the course, + and the first lookup of that id stores it; see + ``ComputedGradeAnonymousIdTest``. + """ + anonymous_id_for_user(user, course_key) + + +def written_tables(captured): + """Return the table each write statement in ``captured`` writes, in order, leaving out the session table.""" + tables = [] + for query in captured.captured_queries: + match = _WRITTEN_TABLE.match(query['sql']) + if match and match.group(1) != 'django_session': + tables.append(match.group(1)) + return tables + + +#: The name of the cohort that learners with no cohort are assigned to in ``CohortedCourseMixin``. +AUTO_COHORT = 'Auto cohort' + +#: What the first cohort-aware read of an uncohorted learner stores: the membership row and the +#: link from the cohort's user group to the learner. +COHORT_ASSIGNMENT_TABLES = ['course_groups_cohortmembership', 'course_groups_courseusergroup_users'] + +#: The tracking events that assignment emits, in order. +COHORT_ASSIGNMENT_TRACKING = ['edx.cohort.user_added', 'edx.cohort.user_add_requested'] + + +class CohortedCourseMixin: + """ + Makes the test course cohorted, with a content-group partition that places + learners by cohort, and enrolls a learner who is in no cohort yet. + + On such a course, any read that asks which content group a learner is in + assigns that learner to a cohort first; see ``capture_cohort_assignment``. + The course of the shared fixture has neither, so no other test reaches that + branch. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + course = cls.store.get_course(cls.course_key) + course.user_partitions = [ + UserPartition( + 50, 'Content groups', 'Groups of learners who see different content', + [Group(1, 'Group A'), Group(2, 'Group B')], + scheme_id='cohort', + ), + ] + cls.store.update_item(course, ModuleStoreEnum.UserID.test) + + def setUp(self): + super().setUp() + config_course_cohorts(self.store.get_course(self.course_key), is_cohorted=True, auto_cohorts=[AUTO_COHORT]) + # Storing the cohort settings publishes the course again, so the block structure is stale. + warm_read_caches(self.course_key) + warm_cohort_settings(self.course_key) + self.uncohorted = UserFactory(username='uncohorted', password=self.password) + CourseEnrollmentFactory(user=self.uncohorted, course_id=self.course_key) + warm_anonymous_id(self.uncohorted, self.course_key) + + def cohort_of(self, user): + """Return the name of the cohort ``user`` is in, in the test course, or None.""" + membership = CohortMembership.objects.filter(user=user, course_id=self.course_key).first() + return membership.course_user_group.name if membership else None + + +@contextmanager +def capture_cohort_assignment(): + """ + Record what the enclosed block writes and announces about cohort membership. + + Yields a namespace that, on exit, holds ``tables`` (every table written, in + order, the session table left out), ``memberships`` (``(username, cohort + name)`` for each membership-changed event) and ``tracking`` (the name of each + cohort tracking event). The events are recorded instead of sent. + """ + record = SimpleNamespace(tables=[], memberships=[], tracking=[]) + with mock.patch.object(COHORT_MEMBERSHIP_CHANGED, 'send_event') as membership_sent, \ + mock.patch.object(cohorts, 'tracker') as tracker, \ + CaptureQueriesContext(connection) as captured: + yield record + record.tables = written_tables(captured) + record.memberships = [ + (call.kwargs['cohort'].user.pii.username, call.kwargs['cohort'].name) + for call in membership_sent.call_args_list + ] + record.tracking = [call.args[0] for call in tracker.emit.call_args_list] + + +def _is_session_write(sql): + """Return True for a statement that writes the session table.""" + return _SESSION_WRITE.match(sql) is not None + + +class ReadOnlyRequestMixin: + """Asserts that a request leaves the stored data exactly as it found it.""" + + @contextmanager + def assert_nothing_is_written(self): + """ + Fail unless the enclosed block issues no INSERT, UPDATE or DELETE, leaves + every grade and enrollment table at the same row count, and sends no + course-import or grade-change signal. + + Writes to the session table are left out: request middleware saves the + session on every view, whatever the view does. + """ + counts_before = {model: model.objects.count() for model in DOMAIN_TABLES} + with mock.patch.object(IMPORT_COURSE_DETAILS, 'send') as course_details_sent, \ + mock.patch.object(COURSE_GRADE_CHANGED, 'send') as grade_changed_sent, \ + CaptureQueriesContext(connection) as captured: + yield + writes = [ + query['sql'] for query in captured.captured_queries + if query['sql'].lstrip().upper().startswith(_WRITE_STATEMENTS) + and not _is_session_write(query['sql']) + ] + assert writes == [], f'the request wrote: {writes}' + assert course_details_sent.call_args_list == [], 'the request sent IMPORT_COURSE_DETAILS' + assert grade_changed_sent.call_args_list == [], 'the request sent COURSE_GRADE_CHANGED' + counts_after = {model: model.objects.count() for model in DOMAIN_TABLES} + assert counts_after == counts_before, f'row counts changed: {counts_before} -> {counts_after}' + + +def count_queries(client, url): + """Return ``(response, number of queries)`` for a GET of ``url``.""" + with CaptureQueriesContext(connection) as captured: + response = client.get(url) + return response, len(captured.captured_queries) + + +def count_post_queries(client, url, body): + """Return ``(response, number of queries)`` for a POST of ``body``, sent as JSON, to ``url``.""" + with CaptureQueriesContext(connection) as captured: + response = client.post(url, data=json.dumps(body), content_type='application/json') + return response, len(captured.captured_queries) diff --git a/lms/djangoapps/grades/rest_api/v2/tests/parity.py b/lms/djangoapps/grades/rest_api/v2/tests/parity.py new file mode 100644 index 000000000000..9c63d4390693 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/parity.py @@ -0,0 +1,95 @@ +""" +Compare a v1 response body with the v2 body that replaces it. + +The v1 body is first rewritten by the declared key renames, then both are +flattened to ``{json path: value}`` and diffed. The comparison fails on any +difference that no declared difference covers, and on any declared difference +or rename that did not occur, so the declarations can neither hide a change +nor go stale. +""" + +import re + +#: Members of an error body that legitimately differ per request. +VOLATILE_FIELDS = {'instance'} + + +def rename_keys(data, renames): + """Return ``data`` with every dict key named in ``renames`` replaced, at any depth.""" + if isinstance(data, dict): + return {renames.get(key, key): rename_keys(value, renames) for key, value in data.items()} + if isinstance(data, list): + return [rename_keys(item, renames) for item in data] + return data + + +def keys_anywhere(data): + """Return every dict key that occurs in ``data``, at any depth.""" + if isinstance(data, dict): + found = set(data) + for value in data.values(): + found |= keys_anywhere(value) + return found + if isinstance(data, list): + found = set() + for item in data: + found |= keys_anywhere(item) + return found + return set() + + +def flatten(data, path=''): + """Return ``{json path: leaf value}`` for ``data``, leaving out volatile members.""" + flat = {} + if isinstance(data, dict): + if not data: + flat[path] = {} + for key in sorted(data): + if key in VOLATILE_FIELDS: + continue + flat.update(flatten(data[key], f'{path}.{key}' if path else key)) + elif isinstance(data, list): + if not data: + flat[path] = [] + for index, item in enumerate(data): + flat.update(flatten(item, f'{path}[{index}]')) + else: + flat[path] = data + return flat + + +def _pattern(declared_path): + """Return a regex matching ``declared_path``, where ``[*]`` stands for any list index.""" + escaped = re.escape(declared_path).replace(r'\[\*\]', r'\[\d+\]') + return re.compile(rf'^{escaped}(?:$|[.\[])') + + +def assert_parity(legacy, new, renames=None, differences=()): + """ + Fail unless ``new`` equals ``legacy`` apart from the declared renames and differences. + + Arguments: + legacy: the decoded v1 body. + new: the decoded v2 body. + renames (dict): v1 key -> v2 key, applied to the v1 body before diffing. + Each must name a key present in the v1 body. + differences: ``(json path, reason)`` pairs, ``[*]`` matching any index. + Each must match at least one differing path. + """ + renames = renames or {} + missing_renames = set(renames) - keys_anywhere(legacy) + assert not missing_renames, f'declared renames did not occur: {sorted(missing_renames)}' + + flat_legacy = flatten(rename_keys(legacy, renames)) + flat_new = flatten(new) + changed = { + path for path in set(flat_legacy) | set(flat_new) + if flat_legacy.get(path, '') != flat_new.get(path, '') + } + patterns = [(_pattern(path), path, reason) for path, reason in differences] + unexpected = sorted(path for path in changed if not any(pattern.match(path) for pattern, _, _ in patterns)) + assert not unexpected, f'undeclared differences: {unexpected}\nv1={legacy}\nv2={new}' + for pattern, path, reason in patterns: + assert any(pattern.match(changed_path) for changed_path in changed), ( + f'declared difference did not occur: {path} ({reason})' + ) diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py b/lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py new file mode 100644 index 000000000000..36a23f6d14a7 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py @@ -0,0 +1,1011 @@ +"""Tests for the course-grade endpoints of the Grades API v2.""" + +from unittest.mock import patch + +import ddt +from django.test.utils import override_settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory, APITestCase + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, StaffFactory, UserFactory +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory +from lms.djangoapps.grades.models import PersistentCourseGrade +from lms.djangoapps.grades.rest_api.v1.tests.mixins import GradeViewTestMixin +from lms.djangoapps.grades.rest_api.v2.filters import CourseGradeFilterSet +from lms.djangoapps.grades.rest_api.v2.pagination import GradePagination +from lms.djangoapps.grades.rest_api.v2.scoping import CourseGradeScopingPolicy +from lms.djangoapps.grades.rest_api.v2.tests.mixins import ( + AUTO_COHORT, + COHORT_ASSIGNMENT_TABLES, + COHORT_ASSIGNMENT_TRACKING, + PAGE_FIELDS, + CohortedCourseMixin, + ReadOnlyRequestMixin, + capture_cohort_assignment, + count_queries, + course_grade_detail_url, + course_grade_list_url, + token_header, + usernames_in, + warm_read_caches, + with_query, +) +from lms.djangoapps.grades.rest_api.v2.tests.parity import assert_parity +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +#: Every learner the shared fixture enrolls in the test course, in enrollment order. +FIXTURE_LEARNERS = ['student', 'other_student', 'program_student', 'program_masters_student'] + + +def persist_passing_grade(user, course_key): + """Store a passing course grade of 62% for ``user``.""" + PersistentCourseGrade.objects.create( + user_id=user.id, + course_id=course_key, + percent_grade=0.62, + letter_grade='Pass', + grading_policy_hash='policy-hash', + ) + + +class CourseGradeTestBase(GradeViewTestMixin, APITestCase): + """Fixtures shared by the course-grade endpoint tests.""" + + def setUp(self): + super().setUp() + for course_key in (self.course_key, self.empty_course.id): + # A published course's overview carries the organization of its key. + CourseOverview.objects.filter(id=course_key).update(org=course_key.org) + warm_read_caches(course_key) + + def expected_grade(self, user, email='', course_key=None): + """Return the row the endpoints return for a learner who has attempted nothing.""" + return { + 'username': user.username, + 'course_key': str(course_key or self.course_key), + 'email': email, + 'passed': False, + 'percent': 0.0, + 'letter_grade': None, + } + + def login(self, user): + """Sign ``user`` in with a browser session.""" + self.client.login(username=user.username, password=self.password) + + def enroll(self, user, course_key, **kwargs): + """Enroll ``user`` in ``course_key``.""" + return CourseEnrollmentFactory(user=user, course_id=course_key, **kwargs) + + +class CourseGradeListAccessTest(CourseGradeTestBase): + """Who may list course grades, and whose grades each caller is shown.""" + + def test_anonymous_caller_is_refused(self): + response = self.client.get(course_grade_list_url()) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug='authn') + + def test_learner_sees_only_their_own_grade(self): + self.login(self.student) + response = self.client.get(course_grade_list_url()) + assert response.status_code == status.HTTP_200_OK + assert response.json()['results'] == [self.expected_grade(self.student)] + + def test_learner_cannot_list_another_learners_grade(self): + self.login(self.student) + response = self.client.get(course_grade_list_url(username=self.other_student.username)) + assert response.status_code == status.HTTP_200_OK + assert response.json()['count'] == 0 + assert response.json()['results'] == [] + + def test_learner_naming_the_course_still_sees_only_their_own_grade(self): + self.login(self.student) + response = self.client.get(course_grade_list_url(course_key=str(self.course_key))) + assert usernames_in(response) == ['student'] + + def test_learner_sees_their_grades_across_courses(self): + self.enroll(self.student, self.empty_course.id) + self.login(self.student) + response = self.client.get(course_grade_list_url()) + assert response.json()['results'] == [ + self.expected_grade(self.student), + self.expected_grade(self.student, course_key=self.empty_course.id), + ] + + def test_course_staff_see_only_their_own_grade(self): + course_staff = StaffFactory.create(course_key=self.course_key, password=self.password) + self.enroll(course_staff, self.course_key) + self.login(course_staff) + response = self.client.get(course_grade_list_url(course_key=str(self.course_key))) + assert usernames_in(response) == [course_staff.username] + + def test_global_staff_see_every_grade_in_the_course(self): + self.client.force_authenticate(self.global_staff) + response = self.client.get(course_grade_list_url(course_key=str(self.course_key))) + assert response.status_code == status.HTTP_200_OK + assert response.json()['results'] == [ + self.expected_grade(self.student), + self.expected_grade(self.other_student), + self.expected_grade(self.program_student), + self.expected_grade(self.program_masters_student, email=self.program_masters_student.email), + ] + + def test_global_staff_list_spans_courses_without_a_course_key(self): + self.enroll(self.other_student, self.empty_course.id) + self.client.force_authenticate(self.global_staff) + response = self.client.get(course_grade_list_url()) + assert [(row['username'], row['course_key']) for row in response.json()['results']] == [ + *[(name, str(self.course_key)) for name in FIXTURE_LEARNERS], + ('other_student', str(self.empty_course.id)), + ] + + def test_inactive_enrollments_are_not_listed(self): + CourseEnrollment.objects.filter(user=self.other_student, course_id=self.course_key).update(is_active=False) + self.client.force_authenticate(self.global_staff) + response = self.client.get(course_grade_list_url(course_key=str(self.course_key))) + assert usernames_in(response) == ['student', 'program_student', 'program_masters_student'] + + def test_deactivated_account_session_is_refused(self): + self.student.is_active = False + self.student.save() + self.client.login(username='student', password=self.password) + response = self.client.get(course_grade_list_url()) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug='authn') + + def test_deactivated_account_token_is_admitted(self): + self.student.is_active = False + self.student.save() + response = self.client.get(course_grade_list_url(), **token_header(self.student)) + assert response.status_code == status.HTTP_200_OK + assert usernames_in(response) == ['student'] + + def test_unrestricted_token_scopes_like_a_session(self): + response = self.client.get(course_grade_list_url(), **token_header(self.student, scopes=())) + assert usernames_in(response) == ['student'] + + +class RestrictedTokenListTest(CourseGradeTestBase): + """A token issued to a restricted application sees what its scope and filters allow.""" + + def org_filter(self, org=None): + return f'content_org:{org or self.course_key.org}' + + def test_token_without_the_read_scope_is_refused(self): + headers = token_header(self.global_staff, scopes=(), is_restricted=True, filters=[self.org_filter()]) + response = self.client.get(course_grade_list_url(), **headers) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz') + + def test_token_without_an_organization_filter_sees_nothing(self): + response = self.client.get(course_grade_list_url(), **token_header(self.global_staff, is_restricted=True)) + assert response.status_code == status.HTTP_200_OK + assert response.json()['count'] == 0 + + def test_token_for_another_organization_sees_nothing(self): + headers = token_header(self.global_staff, is_restricted=True, filters=[self.org_filter('NotThisOrg')]) + response = self.client.get(course_grade_list_url(), **headers) + assert response.json()['count'] == 0 + + def test_organization_filter_admits_every_learner_in_the_organization(self): + headers = token_header(self.student, is_restricted=True, filters=[self.org_filter()]) + response = self.client.get(course_grade_list_url(), **headers) + assert usernames_in(response) == FIXTURE_LEARNERS + + def test_user_filter_narrows_to_that_learner(self): + headers = token_header( + self.global_staff, is_restricted=True, filters=[self.org_filter(), 'user:other_student'], + ) + response = self.client.get(course_grade_list_url(), **headers) + assert usernames_in(response) == ['other_student'] + + def test_me_user_filter_means_the_token_holder(self): + headers = token_header(self.student, is_restricted=True, filters=[self.org_filter(), 'user:me']) + response = self.client.get(course_grade_list_url(), **headers) + assert usernames_in(response) == ['student'] + + def test_only_the_first_user_filter_counts(self): + headers = token_header( + self.global_staff, is_restricted=True, + filters=[self.org_filter(), 'user:other_student', 'user:student'], + ) + response = self.client.get(course_grade_list_url(), **headers) + assert usernames_in(response) == ['other_student'] + + def test_lower_case_user_filter_matches_a_username_with_capitals(self): + self.enroll(UserFactory(username='MixedCase'), self.course_key) + headers = token_header(self.global_staff, is_restricted=True, filters=[self.org_filter(), 'user:mixedcase']) + response = self.client.get(course_grade_list_url(), **headers) + assert usernames_in(response) == ['MixedCase'] + + def test_user_filter_with_capitals_matches_nobody(self): + self.enroll(UserFactory(username='MixedCase'), self.course_key) + headers = token_header(self.global_staff, is_restricted=True, filters=[self.org_filter(), 'user:MixedCase']) + response = self.client.get(course_grade_list_url(), **headers) + assert response.status_code == status.HTTP_200_OK + assert response.json()['count'] == 0 + + def test_me_user_filter_matches_a_token_holder_with_capitals(self): + holder = UserFactory(username='MixedCase') + self.enroll(holder, self.course_key) + headers = token_header(holder, is_restricted=True, filters=[self.org_filter(), 'user:me']) + response = self.client.get(course_grade_list_url(), **headers) + assert usernames_in(response) == ['MixedCase'] + + +@ddt.ddt +class RestrictedTokenUserFilterParityTest(CourseGradeTestBase): + """ + A restricted token reads one learner's grade in v2 exactly when v1's + single-learner address lets the same token read it. + + v1 refuses with 403 where v2 answers 404, because v2 hides whether the + grade exists; the comparison is of whether the grade is served. + """ + + def setUp(self): + super().setUp() + self.mixed_case = UserFactory(username='MixedCase') + self.enroll(self.mixed_case, self.course_key) + + @ddt.data( + ('student', ['user:student']), + ('student', ['user:other_student']), + ('student', ['user:student', 'user:other_student']), + ('student', ['user:other_student', 'user:student']), + ('MixedCase', ['user:mixedcase']), + ('MixedCase', ['user:MixedCase']), + ('MixedCase', ['user:me']), + ('student', []), + ) + @ddt.unpack + def test_served_exactly_when_v1_serves_it(self, username, user_filters): + holder = self.mixed_case if 'user:me' in user_filters else self.global_staff + headers = token_header( + holder, is_restricted=True, filters=[f'content_org:{self.course_key.org}', *user_filters], + ) + legacy_url = reverse('grades_api:v1:course_grades', kwargs={'course_id': str(self.course_key)}) + legacy = self.client.get(legacy_url, {'username': username}, **headers) + new = self.client.get(course_grade_detail_url(username, self.course_key), **headers) + assert legacy.status_code in (status.HTTP_200_OK, status.HTTP_403_FORBIDDEN), legacy.content + assert new.status_code in (status.HTTP_200_OK, status.HTTP_404_NOT_FOUND), new.content + assert (new.status_code == status.HTTP_200_OK) == (legacy.status_code == status.HTTP_200_OK) + if new.status_code == status.HTTP_200_OK: + assert new.json()['username'] == legacy.json()[0]['username'] == username + + +@ddt.ddt +class CourseGradeFullViewAccessTest(CourseGradeTestBase): + """ + Only global staff, signed in as themselves, may read the breakdown. + + The breakdown carries scores the course hides from learners, so it is + refused to learners asking about themselves, to course staff, and to any + restricted token, whatever its filters and whoever it was issued to. + """ + + def assert_refused(self, **headers): + """Fail unless the breakdown is refused on both addresses.""" + for url in ( + course_grade_list_url(course_key=str(self.course_key), view='full'), + course_grade_detail_url('student', self.course_key, view='full'), + ): + response = self.client.get(url, **headers) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz') + + def org_token(self, user): + """Return the headers of a restricted token for ``user`` naming the test course's organization.""" + return token_header(user, is_restricted=True, filters=[f'content_org:{self.course_key.org}']) + + def test_learner_is_refused_their_own_breakdown(self): + self.login(self.student) + self.assert_refused() + + def test_learner_still_reads_their_own_grade_without_it(self): + self.login(self.student) + response = self.client.get(course_grade_detail_url('student', self.course_key)) + assert response.json() == self.expected_grade(self.student) + + def test_learner_token_is_refused(self): + self.assert_refused(**token_header(self.student)) + + def test_course_staff_are_refused(self): + course_staff = StaffFactory.create(course_key=self.course_key, password=self.password) + self.login(course_staff) + self.assert_refused() + + def test_restricted_token_for_a_learner_is_refused(self): + self.assert_refused(**self.org_token(self.student)) + + def test_restricted_token_for_global_staff_is_refused(self): + self.assert_refused(**self.org_token(self.global_staff)) + + def test_restricted_token_keeps_the_default_view(self): + response = self.client.get(course_grade_list_url(), **self.org_token(self.student)) + assert usernames_in(response) == FIXTURE_LEARNERS + + @ddt.data('session', 'token') + def test_global_staff_are_admitted(self, how): + headers = token_header(self.global_staff) if how == 'token' else {} + if how == 'session': + self.login(self.global_staff) + response = self.client.get(course_grade_detail_url('student', self.course_key, view='full'), **headers) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['section_breakdown']) == 28 + + def test_unknown_view_is_still_a_validation_error_for_a_learner(self): + self.login(self.student) + response = self.client.get(course_grade_list_url(view='bogus')) + assert_error_envelope(response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='validation') + + +@ddt.ddt +class CourseGradeFullViewParityTest(CourseGradeTestBase): + """The breakdown admits exactly the callers v1's section breakdown admits.""" + + def headers_for(self, caller): + """Sign ``caller`` in, and return the request headers it sends.""" + org_filter = f'content_org:{self.course_key.org}' + if caller == 'anonymous': + return {} + if caller.endswith('_session'): + user = { + 'learner_session': self.student, + 'course_staff_session': StaffFactory.create(course_key=self.course_key, password=self.password), + 'global_staff_session': self.global_staff, + }[caller] + self.login(user) + return {} + user = self.global_staff if caller.startswith('global_staff') else self.student + if caller.endswith('_restricted_token'): + return token_header(user, is_restricted=True, filters=[org_filter]) + return token_header(user) + + @ddt.data( + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ('learner_session', status.HTTP_403_FORBIDDEN), + ('course_staff_session', status.HTTP_403_FORBIDDEN), + ('global_staff_session', status.HTTP_200_OK), + ('learner_token', status.HTTP_403_FORBIDDEN), + ('global_staff_token', status.HTTP_200_OK), + ('learner_restricted_token', status.HTTP_403_FORBIDDEN), + ('global_staff_restricted_token', status.HTTP_403_FORBIDDEN), + ) + @ddt.unpack + def test_same_status_as_v1(self, caller, expected): + headers = self.headers_for(caller) + legacy = self.client.get( + reverse('grades_api:v1:section_grades_breakdown'), {'course_id': str(self.course_key)}, **headers, + ) + new_list = self.client.get(course_grade_list_url(course_key=str(self.course_key), view='full'), **headers) + new_detail = self.client.get(course_grade_detail_url('student', self.course_key, view='full'), **headers) + assert (legacy.status_code, new_list.status_code, new_detail.status_code) == (expected, expected, expected) + + +class CourseGradeVisibilityTest(CourseGradeTestBase): + """Each branch of the scoping policy, applied directly to the enrollment table.""" + + def scoped_usernames(self, user, auth=None, restricted=False, filters=()): + """Return the usernames the policy leaves visible to ``user``.""" + request = APIRequestFactory().get('/') + request.user = user + request.auth = auth + policy = CourseGradeScopingPolicy(request) + with patch.object(policy, '_is_restricted_token', return_value=restricted), \ + patch('lms.djangoapps.grades.rest_api.v2.scoping.decode_jwt_filters', return_value=list(filters)): + queryset = policy.scope(CourseEnrollment.objects.order_by('id'), user) + return list(queryset.values_list('user__username', flat=True)) + + def test_staff_see_every_row(self): + assert self.scoped_usernames(self.global_staff) == FIXTURE_LEARNERS + + def test_other_callers_see_their_own_rows(self): + assert self.scoped_usernames(self.other_student) == ['other_student'] + + def test_staff_restricted_token_is_still_filtered(self): + assert not self.scoped_usernames(self.global_staff, restricted=True) + + def test_restricted_token_organization_and_user_filters(self): + filters = [('content_org', self.course_key.org), ('user', 'program_student')] + assert self.scoped_usernames(self.student, restricted=True, filters=filters) == ['program_student'] + + def test_restricted_token_me_filter(self): + filters = [('content_org', self.course_key.org), ('user', 'me')] + assert self.scoped_usernames(self.student, restricted=True, filters=filters) == ['student'] + + def test_restricted_token_later_user_filters_are_ignored(self): + filters = [('user', 'program_student'), ('content_org', self.course_key.org), ('user', 'student')] + assert self.scoped_usernames(self.student, restricted=True, filters=filters) == ['program_student'] + + +class CourseGradeDetailAccessTest(CourseGradeTestBase): + """Who may read one learner's grade.""" + + def test_anonymous_caller_is_refused(self): + response = self.client.get(course_grade_detail_url('student', self.course_key)) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug='authn') + + def test_learner_reads_their_own_grade(self): + self.login(self.student) + response = self.client.get(course_grade_detail_url('student', self.course_key)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == self.expected_grade(self.student) + + def test_learner_cannot_read_another_learners_grade(self): + self.login(self.student) + response = self.client.get(course_grade_detail_url('other_student', self.course_key)) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + def test_course_staff_cannot_read_a_learners_grade(self): + course_staff = StaffFactory.create(course_key=self.course_key, password=self.password) + self.login(course_staff) + response = self.client.get(course_grade_detail_url('student', self.course_key)) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + def test_global_staff_read_any_learners_grade(self): + self.client.force_authenticate(self.global_staff) + response = self.client.get(course_grade_detail_url('program_masters_student', self.course_key)) + assert response.json() == self.expected_grade( + self.program_masters_student, email=self.program_masters_student.email, + ) + + def test_inactive_enrollment_is_still_readable(self): + CourseEnrollment.objects.filter(user=self.student, course_id=self.course_key).update(is_active=False) + self.login(self.student) + response = self.client.get(course_grade_detail_url('student', self.course_key)) + assert response.json() == self.expected_grade(self.student) + + def test_learner_not_enrolled_is_not_found(self): + self.client.force_authenticate(self.global_staff) + response = self.client.get(course_grade_detail_url('student', self.empty_course.id)) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + def test_unknown_learner_is_not_found(self): + self.client.force_authenticate(self.global_staff) + response = self.client.get(course_grade_detail_url('nobody', self.course_key)) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + def test_restricted_token_for_another_learner_is_not_found(self): + headers = token_header( + self.global_staff, is_restricted=True, + filters=[f'content_org:{self.course_key.org}', 'user:other_student'], + ) + response = self.client.get(course_grade_detail_url('student', self.course_key), **headers) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + def test_restricted_token_for_the_named_learner_is_admitted(self): + headers = token_header( + self.global_staff, is_restricted=True, + filters=[f'content_org:{self.course_key.org}', 'user:student'], + ) + response = self.client.get(course_grade_detail_url('student', self.course_key), **headers) + assert response.json() == self.expected_grade(self.student) + + def test_restricted_token_without_the_read_scope_is_refused(self): + headers = token_header( + self.student, scopes=(), is_restricted=True, filters=[f'content_org:{self.course_key.org}'], + ) + response = self.client.get(course_grade_detail_url('student', self.course_key), **headers) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz') + + def test_grade_that_cannot_be_computed_is_a_server_error(self): + self.client.force_authenticate(self.global_staff) + with patch.object(CourseGradeFactory, 'read', side_effect=RuntimeError('boom')): + response = self.client.get(course_grade_detail_url('student', self.course_key)) + body = assert_error_envelope( + response, expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, expected_type_slug='internal', + ) + assert 'boom' not in body['detail'] + + +@ddt.ddt +class CourseGradeFilterTest(CourseGradeTestBase): + """Each filter narrows the rows an authorized caller sees, and bad values are refused.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + self.enroll(self.student, self.empty_course.id) + + def test_course_key_narrows_to_one_course(self): + response = self.client.get(course_grade_list_url(course_key=str(self.empty_course.id))) + assert response.json()['results'] == [self.expected_grade(self.student, course_key=self.empty_course.id)] + + def test_unknown_course_is_an_empty_page(self): + response = self.client.get(course_grade_list_url(course_key='course-v1:NoSuch+Course+Run')) + assert response.status_code == status.HTTP_200_OK + assert response.json()['count'] == 0 + + @ddt.data('not-a-course-key', 'edX/DemoX/Demo') + def test_malformed_or_retired_course_key_is_refused(self, course_key): + response = self.client.get(course_grade_list_url(course_key=course_key)) + body = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='validation', + ) + assert list(body['errors']) == ['course_key'] + + def test_username_narrows_to_one_learner(self): + response = self.client.get(course_grade_list_url(course_key=str(self.course_key), username='other_student')) + assert usernames_in(response) == ['other_student'] + + def test_username_accepts_a_comma_separated_list(self): + response = self.client.get(course_grade_list_url(username='program_student,student')) + assert [(row['username'], row['course_key']) for row in response.json()['results']] == [ + ('student', str(self.course_key)), + ('program_student', str(self.course_key)), + ('student', str(self.empty_course.id)), + ] + + def test_more_than_a_hundred_usernames_are_refused(self): + response = self.client.get(course_grade_list_url(username=','.join(f'u{i}' for i in range(101)))) + body = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='validation', + ) + assert list(body['errors']) == ['username'] + + def test_a_hundred_usernames_are_accepted(self): + names = ['student'] + [f'u{i}' for i in range(99)] + response = self.client.get(course_grade_list_url(course_key=str(self.course_key), username=','.join(names))) + assert usernames_in(response) == ['student'] + + @ddt.data( + ('username', ['other_student', 'program_masters_student', 'program_student', 'student']), + ('-username', ['student', 'program_student', 'program_masters_student', 'other_student']), + ('-id', list(reversed(FIXTURE_LEARNERS))), + ('created', FIXTURE_LEARNERS), + ) + @ddt.unpack + def test_ordering(self, ordering, expected): + response = self.client.get(course_grade_list_url(course_key=str(self.course_key), ordering=ordering)) + assert usernames_in(response) == expected + + @ddt.data(('created', ('created', 'id')), ('-username', ('-user__username', 'id'))) + @ddt.unpack + def test_ordering_breaks_ties_on_id(self, ordering, expected): + filterset = CourseGradeFilterSet(data={'ordering': ordering}, queryset=CourseEnrollment.objects.all()) + assert filterset.qs.query.order_by == expected + + def test_default_order_is_enrollment_order(self): + response = self.client.get(course_grade_list_url(course_key=str(self.course_key))) + assert usernames_in(response) == FIXTURE_LEARNERS + + def test_unknown_ordering_is_refused(self): + response = self.client.get(course_grade_list_url(ordering='bogus')) + body = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='validation', + ) + assert list(body['errors']) == ['ordering'] + + def test_filters_do_not_widen_a_learners_view(self): + self.client.force_authenticate(self.other_student) + response = self.client.get(course_grade_list_url(course_key=str(self.course_key), username='student')) + assert response.json()['count'] == 0 + + +class CourseGradeRepresentationTest(CourseGradeTestBase): + """The default and full representations, and refusal of any other.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def test_default_rows_carry_no_breakdown(self): + response = self.client.get(course_grade_list_url(course_key=str(self.course_key))) + assert all('section_breakdown' not in row for row in response.json()['results']) + + def test_full_view_adds_the_breakdown_to_each_row(self): + response = self.client.get(course_grade_list_url(course_key=str(self.course_key), view='full')) + rows = response.json()['results'] + assert [row['username'] for row in rows] == FIXTURE_LEARNERS + homework = rows[0]['section_breakdown'][0] + assert homework == { + 'category': 'Homework', + 'label': 'HW 01', + 'detail': 'Homework 1 Unreleased - 0% (?/?)', + 'percent': 0.0, + 'usage_key': None, + } + summaries = [entry for entry in rows[0]['section_breakdown'] if entry.get('prominent')] + assert [entry['label'] for entry in summaries] == ['HW Avg', 'Lab Avg', 'Midterm', 'Final'] + dropped = [entry['label'] for entry in rows[0]['section_breakdown'] if 'mark' in entry] + assert dropped == ['HW 11', 'HW 12', 'Lab 11', 'Lab 12'] + + def test_full_view_on_the_member(self): + response = self.client.get(course_grade_detail_url('student', self.course_key, view='full')) + assert len(response.json()['section_breakdown']) == 28 + + def test_unknown_view_is_refused_on_the_collection(self): + response = self.client.get(course_grade_list_url(view='minimal')) + body = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='validation', + ) + assert list(body['errors']) == ['view'] + + def test_unknown_view_is_refused_on_the_member(self): + response = self.client.get(course_grade_detail_url('student', self.course_key, view='bogus')) + assert_error_envelope(response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='validation') + + def test_learner_whose_grade_cannot_be_computed_is_left_out_of_the_page(self): + real_read = CourseGradeFactory.read + + def read(factory, user, *args, **kwargs): + if user.username == 'other_student': + raise RuntimeError('cannot grade') + return real_read(factory, user, *args, **kwargs) + + with patch.object(CourseGradeFactory, 'read', autospec=True, side_effect=read): + response = self.client.get(course_grade_list_url(course_key=str(self.course_key))) + assert usernames_in(response) == ['student', 'program_student', 'program_masters_student'] + + +class CourseGradePagingTest(CourseGradeTestBase): + """The list pages with the standard envelope and limits.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + for index in range(8): + self.enroll(UserFactory(username=f'extra_{index}'), self.course_key) + + def test_body_carries_the_seven_page_members(self): + body = self.client.get(course_grade_list_url(course_key=str(self.course_key))).json() + assert set(body) == PAGE_FIELDS + assert (body['count'], body['num_pages'], body['current_page'], body['start']) == (12, 2, 1, 0) + assert len(body['results']) == 10 + assert body['previous'] is None + assert body['next'].endswith('page=2') + + def test_second_page(self): + body = self.client.get(course_grade_list_url(course_key=str(self.course_key), page=2)).json() + assert (body['current_page'], body['start']) == (2, 10) + assert [row['username'] for row in body['results']] == ['extra_6', 'extra_7'] + + def test_page_size(self): + body = self.client.get(course_grade_list_url(course_key=str(self.course_key), page_size=5)).json() + assert (len(body['results']), body['num_pages']) == (5, 3) + + def test_page_size_zero_falls_back_to_the_default(self): + response = self.client.get(course_grade_list_url(course_key=str(self.course_key), page_size=0)) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['results']) == 10 + + def test_page_size_is_capped_at_a_hundred(self): + request = Request(APIRequestFactory().get('/', {'page_size': 101})) + assert GradePagination().get_page_size(request) == 100 + + def test_page_past_the_end_is_not_found(self): + response = self.client.get(course_grade_list_url(course_key=str(self.course_key), page=3)) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + +class CourseGradeParityTest(CourseGradeTestBase): + """ + The v2 bodies equal the v1 bodies they replace, apart from the declared differences. + + v1's section breakdown is served to global staff only, and its course list + to global staff or to the learner named, so those callers are used here. + """ + + PAGE_MEMBERS_ADDED = [ + ('count', 'page-number envelope replaces the cursor envelope'), + ('num_pages', 'page-number envelope replaces the cursor envelope'), + ('current_page', 'page-number envelope replaces the cursor envelope'), + ('start', 'page-number envelope replaces the cursor envelope'), + ] + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + persist_passing_grade(self.other_student, self.course_key) + + def v1_course_grades_url(self, **query): + url = reverse('grades_api:v1:course_grades', kwargs={'course_id': str(self.course_key)}) + return f'{url}?{"&".join(f"{key}={value}" for key, value in query.items())}' if query else url + + def test_course_list(self): + legacy = self.client.get(self.v1_course_grades_url()) + new = self.client.get(course_grade_list_url(course_key=str(self.course_key))) + assert legacy.status_code == new.status_code == status.HTTP_200_OK + assert_parity( + legacy.json(), new.json(), + renames={'course_id': 'course_key'}, + differences=self.PAGE_MEMBERS_ADDED, + ) + + def test_one_learner(self): + legacy = self.client.get(self.v1_course_grades_url(username='student')) + new = self.client.get(course_grade_detail_url('student', self.course_key)) + assert legacy.status_code == new.status_code == status.HTTP_200_OK + assert len(legacy.json()) == 1 + assert_parity(legacy.json()[0], new.json(), renames={'course_id': 'course_key'}) + + def test_one_learner_with_a_stored_grade(self): + legacy = self.client.get(self.v1_course_grades_url(username='other_student')) + new = self.client.get(course_grade_detail_url('other_student', self.course_key)) + assert (new.json()['percent'], new.json()['letter_grade'], new.json()['passed']) == (0.62, 'Pass', True) + assert_parity(legacy.json()[0], new.json(), renames={'course_id': 'course_key'}) + + def test_one_masters_learner(self): + legacy = self.client.get(self.v1_course_grades_url(username='program_masters_student')) + new = self.client.get(course_grade_detail_url('program_masters_student', self.course_key)) + assert_parity( + legacy.json()[0], new.json(), + renames={'course_id': 'course_key'}, + differences=[('email', "v1 hid the master's-track email on the single-learner address only")], + ) + + def test_section_breakdown(self): + url = reverse('grades_api:v1:section_grades_breakdown') + legacy = self.client.get(url, {'course_id': str(self.course_key)}) + new = self.client.get(course_grade_list_url(course_key=str(self.course_key), view='full')) + assert legacy.status_code == new.status_code == status.HTTP_200_OK + assert_parity( + legacy.json(), new.json(), + renames={'course_id': 'course_key', 'sequential_id': 'usage_key'}, + differences=[ + *self.PAGE_MEMBERS_ADDED, + ('results[*].current_grade', 'the whole-number percentage becomes the percent fraction'), + ('results[*].percent', 'the whole-number percentage becomes the percent fraction'), + ('results[*].email', 'rows carry the same members with and without the breakdown'), + ('results[*].letter_grade', 'rows carry the same members with and without the breakdown'), + ('results[*].section_breakdown[*].usage_key', 'present as null on entries v1 gave no subsection'), + ], + ) + for legacy_row, new_row in zip(legacy.json()['results'], new.json()['results'], strict=True): + assert legacy_row['current_grade'] == int(new_row['percent'] * 100) + assert [entry.get('sequential_id') for entry in legacy_row['section_breakdown']] == [ + entry['usage_key'] for entry in new_row['section_breakdown'] + ] + + def test_same_row_set_for_the_same_caller(self): + self.enroll(self.student, self.empty_course.id) + legacy = self.client.get(reverse('grades_api:v1:section_grades_breakdown')) + new = self.client.get(course_grade_list_url()) + assert [(row['username'], row['course_id']) for row in legacy.json()['results']] == [ + (row['username'], row['course_key']) for row in new.json()['results'] + ] + + def test_status_codes_that_changed(self): + """Pins the v1 and v2 statuses where the plan declares they differ.""" + self.client.force_authenticate(self.student) + assert self.client.get(self.v1_course_grades_url()).status_code == status.HTTP_403_FORBIDDEN + assert self.client.get(course_grade_list_url(course_key=str(self.course_key))).status_code == 200 + assert self.client.get(self.v1_course_grades_url(username='other_student')).status_code == 403 + assert self.client.get(course_grade_detail_url('other_student', self.course_key)).status_code == 404 + self.client.force_authenticate(self.global_staff) + unknown = 'course-v1:NoSuch+Course+Run' + legacy = self.client.get(reverse('grades_api:v1:course_grades', kwargs={'course_id': unknown})) + assert legacy.status_code == status.HTTP_404_NOT_FOUND + assert self.client.get(course_grade_list_url(course_key=unknown)).status_code == status.HTTP_200_OK + + +class CourseGradeReadOnlyTest(ReadOnlyRequestMixin, CourseGradeTestBase): + """Reading course grades changes nothing and announces nothing.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def test_list(self): + with self.assert_nothing_is_written(): + assert self.client.get(course_grade_list_url(course_key=str(self.course_key))).status_code == 200 + + def test_list_full_view(self): + with self.assert_nothing_is_written(): + response = self.client.get(course_grade_list_url(view='full')) + assert response.status_code == status.HTTP_200_OK + + def test_detail_full_view(self): + with self.assert_nothing_is_written(): + response = self.client.get(course_grade_detail_url('student', self.course_key, view='full')) + assert response.status_code == status.HTTP_200_OK + + def test_head(self): + with self.assert_nothing_is_written(): + assert self.client.head(course_grade_list_url()).status_code == status.HTTP_200_OK + assert self.client.head(course_grade_detail_url('student', self.course_key)).status_code == 200 + + +class CourseGradeCohortAssignmentTest(CohortedCourseMixin, CourseGradeTestBase): + """ + On a course whose content groups follow cohorts, the per-subsection + breakdown assigns a learner who is in no cohort to one, as v1 does. + + Working out which subsections a learner can see asks for the learner's + content group, and the cohort lookup behind it assigns a cohort to a learner + who has none, storing the membership and announcing it. Only the breakdown + walks the content per learner; the default view does not. This pins both + sides so a change to either is deliberate. + """ + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def assert_assigned_once(self, url): + with capture_cohort_assignment() as first: + assert self.client.get(url).status_code == status.HTTP_200_OK + assert first.tables == COHORT_ASSIGNMENT_TABLES + assert first.memberships == [('uncohorted', AUTO_COHORT)] + assert first.tracking == COHORT_ASSIGNMENT_TRACKING + assert self.cohort_of(self.uncohorted) == AUTO_COHORT + with capture_cohort_assignment() as second: + assert self.client.get(url).status_code == status.HTTP_200_OK + assert (second.tables, second.memberships, second.tracking) == ([], [], []) + + def assert_not_assigned(self, url): + with capture_cohort_assignment() as record: + assert self.client.get(url).status_code == status.HTTP_200_OK + assert (record.tables, record.memberships, record.tracking) == ([], [], []) + assert self.cohort_of(self.uncohorted) is None + + def test_full_list_assigns_the_learner(self): + self.assert_assigned_once( + course_grade_list_url(course_key=str(self.course_key), username='uncohorted', view='full') + ) + + def test_full_member_assigns_the_learner(self): + self.assert_assigned_once(course_grade_detail_url('uncohorted', self.course_key, view='full')) + + def test_full_list_assigns_every_learner_on_the_page(self): + with capture_cohort_assignment() as record: + response = self.client.get(course_grade_list_url(course_key=str(self.course_key), view='full')) + assert response.status_code == status.HTTP_200_OK + expected = [(username, AUTO_COHORT) for username in [*FIXTURE_LEARNERS, 'uncohorted']] + assert sorted(record.memberships) == sorted(expected) + assert record.tables == COHORT_ASSIGNMENT_TABLES * len(expected) + assert record.tracking == COHORT_ASSIGNMENT_TRACKING * len(expected) + + def test_default_list_assigns_nobody(self): + self.assert_not_assigned(course_grade_list_url(course_key=str(self.course_key))) + + def test_default_member_assigns_nobody(self): + self.assert_not_assigned(course_grade_detail_url('uncohorted', self.course_key)) + + def test_v1_breakdown_assigns_the_learner_too(self): + url = with_query( + reverse('grades_api:v1:section_grades_breakdown'), + course_id=str(self.course_key), username='uncohorted', + ) + self.assert_assigned_once(url) + + +class CourseGradeQueryCountTest(CourseGradeTestBase): + """ + The v2 reads issue no more queries than v1 on the same fixture. The default + list does not grow with the page; the list with the breakdown grows by a + fixed number of queries per row, fewer than v1 grows. + + Each address is requested once before it is measured, so one-off lookups + made by the first request of a test (site, theme, session) count for neither + side. + """ + + #: Measured on this fixture; a change in any of them is a change in the data path. + LIST_QUERIES = 13 + FULL_LIST_QUERIES = 40 + DETAIL_QUERIES = 9 + #: Collecting the content each learner can see checks that learner's access: + #: two enrollment reads, one course-role read, one discussion-role read, one + #: schedule read and one read of the authorization engine's policy version. + FULL_LIST_QUERIES_PER_ROW = 6 + V1_FULL_LIST_QUERIES_PER_ROW = 7 + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def measure(self, url): + """Return the number of queries a second GET of ``url`` issues.""" + assert self.client.get(url).status_code == status.HTTP_200_OK + response, count = count_queries(self.client, url) + assert response.status_code == status.HTTP_200_OK + return count + + def v1_url(self, **query): + url = reverse('grades_api:v1:course_grades', kwargs={'course_id': str(self.course_key)}) + return with_query(url, **query) + + def test_list(self): + legacy = self.measure(self.v1_url()) + new = self.measure(course_grade_list_url(course_key=str(self.course_key))) + assert new == self.LIST_QUERIES + assert new <= legacy + + def test_full_list(self): + legacy_url = with_query(reverse('grades_api:v1:section_grades_breakdown'), course_id=str(self.course_key)) + legacy = self.measure(legacy_url) + new = self.measure(course_grade_list_url(course_key=str(self.course_key), view='full')) + assert new == self.FULL_LIST_QUERIES + assert new <= legacy + + def test_detail(self): + legacy = self.measure(self.v1_url(username='student')) + new = self.measure(course_grade_detail_url('student', self.course_key)) + assert new == self.DETAIL_QUERIES + assert new <= legacy + + def test_full_list_grows_by_a_fixed_count_per_row(self): + for index in range(8): + self.enroll(UserFactory(username=f'extra_{index}'), self.course_key) + course_key = str(self.course_key) + five = self.measure(course_grade_list_url(course_key=course_key, view='full', page_size=5)) + ten = self.measure(course_grade_list_url(course_key=course_key, view='full', page_size=10)) + legacy_url = reverse('grades_api:v1:section_grades_breakdown') + legacy_five = self.measure(with_query(legacy_url, course_id=course_key, page_size=5)) + legacy_ten = self.measure(with_query(legacy_url, course_id=course_key, page_size=10)) + assert (ten - five, legacy_ten - legacy_five) == ( + 5 * self.FULL_LIST_QUERIES_PER_ROW, 5 * self.V1_FULL_LIST_QUERIES_PER_ROW, + ) + assert ten <= legacy_ten + + def test_list_count_does_not_grow_with_the_page_size(self): + for index in range(8): + self.enroll(UserFactory(username=f'extra_{index}'), self.course_key) + five = self.measure(course_grade_list_url(course_key=str(self.course_key), page_size=5)) + ten = self.measure(course_grade_list_url(course_key=str(self.course_key), page_size=10)) + assert five == ten + + +@override_settings(DEBUG=False) +class CourseGradeUrlTest(CourseGradeTestBase): + """The addresses, their names, and the not-found body for addresses no route matches.""" + + COURSE_KEY = 'course-v1:edX+DemoX+Demo_Course' + + def test_reverse_literals(self): + assert reverse('grade_v2:course_grade_list') == '/api/grade/v2/course_grades/' + assert reverse( + 'grade_v2:course_grade_detail', kwargs={'username': 'u', 'course_key': self.COURSE_KEY}, + ) == f'/api/grade/v2/course_grades/u,{self.COURSE_KEY}/' + + def test_member_address_round_trips_keys_with_plus_signs(self): + url = course_grade_detail_url('some.user-1', self.course_key) + self.client.force_authenticate(self.global_staff) + assert self.client.get(url).status_code == status.HTTP_404_NOT_FOUND + CourseEnrollmentFactory(user=UserFactory(username='some.user-1'), course_id=self.course_key) + assert self.client.get(url).status_code == status.HTTP_200_OK + + def test_unmatched_addresses_get_a_json_not_found(self): + self.client.force_authenticate(self.global_staff) + for path in ( + '/api/grade/v2/course_grades/student,not-a-course-key/', + '/api/grade/v2/course_grades/student,edX/DemoX/Demo/', + '/api/grade/v2/course_grades/student/', + f'/api/grade/v2/course_grades/student,{self.course_key}/extra/', + ): + response = self.client.get(path) + assert response['Content-Type'] == 'application/json', path + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + def test_unmatched_addresses_answer_every_method_alike(self): + path = '/api/grade/v2/course_grades/student,not-a-course-key/' + for method in ('post', 'put', 'patch', 'delete', 'options'): + response = getattr(self.client, method)(path) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + assert_error_envelope( + self.client.get(path), expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found', + ) + + def test_missing_trailing_slash_redirects(self): + response = self.client.get(f'/api/grade/v2/course_grades/student,{self.course_key}') + assert response.status_code == status.HTTP_301_MOVED_PERMANENTLY + assert response['Location'].endswith(f'/api/grade/v2/course_grades/student,{self.course_key}/') + + def test_post_on_the_collection_is_not_allowed(self): + self.client.force_authenticate(self.global_staff) + response = self.client.post(course_grade_list_url()) + # edx-drf-extensions does not catalog MethodNotAllowed yet, so its type is + # internal. Expect its own slug once the pinned release catalogs it. + assert_error_envelope( + response, expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, expected_type_slug='internal', + ) + + def test_response_that_cannot_be_json_is_not_acceptable(self): + self.client.force_authenticate(self.global_staff) + response = self.client.get(course_grade_list_url(), HTTP_ACCEPT='text/html') + # edx-drf-extensions does not catalog NotAcceptable yet, so its type is + # internal. Expect its own slug once the pinned release catalogs it. + assert_error_envelope(response, expected_status=status.HTTP_406_NOT_ACCEPTABLE, expected_type_slug='internal') diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_course_grading_policy.py b/lms/djangoapps/grades/rest_api/v2/tests/test_course_grading_policy.py new file mode 100644 index 000000000000..25b5ae340eb1 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_course_grading_policy.py @@ -0,0 +1,575 @@ +"""Tests for the course grading policy endpoint of the Grades API v2.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +import ddt +from ccx_keys.locator import CCXLocator +from django.http import Http404 +from django.test.utils import override_settings +from django.urls import reverse +from django.utils import timezone +from edx_rest_framework_extensions.testing import assert_error_envelope +from edx_toggles.toggles.testutils import override_waffle_flag +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.roles import ( + CourseBetaTesterRole, + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole, + OrgStaffRole, +) +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.ccx.tests.utils import CcxTestCase +from lms.djangoapps.grades.config.waffle import BULK_MANAGEMENT, ENFORCE_FREEZE_GRADE_AFTER_COURSE_END +from lms.djangoapps.grades.grade_utils import are_grades_frozen +from lms.djangoapps.grades.rest_api.v1.tests.mixins import GradeViewTestMixin +from lms.djangoapps.grades.rest_api.v2.services import grades_frozen +from lms.djangoapps.grades.rest_api.v2.tests.mixins import ( + CohortedCourseMixin, + ReadOnlyRequestMixin, + capture_cohort_assignment, + count_queries, + course_grading_policy_url, + warm_read_caches, + with_query, +) +from lms.djangoapps.grades.rest_api.v2.tests.parity import assert_parity +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.lib.courses import get_course_by_id +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.factories import BlockFactory + +MISSING_COURSE_KEY = 'course-v1:NoSuch+Course+Run' +DEFAULT_ASSIGNMENT_TYPES = [ + {'type': 'Homework', 'short_label': 'HW', 'min_count': 12, 'drop_count': 2, 'weight': 0.15}, + {'type': 'Lab', 'short_label': 'Lab', 'min_count': 12, 'drop_count': 2, 'weight': 0.15}, + {'type': 'Midterm Exam', 'short_label': 'Midterm', 'min_count': 1, 'drop_count': 0, 'weight': 0.3}, + {'type': 'Final Exam', 'short_label': 'Final', 'min_count': 1, 'drop_count': 0, 'weight': 0.4}, +] + +#: Graded subsections the shared course fixture holds. +GRADED_SUBSECTIONS = 26 +MINIMAL_MEMBERS = {'grade_cutoffs', 'assignment_types'} +DEFAULT_MEMBERS = {*MINIMAL_MEMBERS, 'subsections', 'grades_frozen', 'bulk_management_enabled'} + + +def reads_of_each_policy_address(client, course_key): + """ + Return the statuses of the v1 policy, v1 grading information, v2 default + and v2 minimal reads of ``course_key``'s grading policy, in that order. + + Fails if a v2 read that succeeds returns other members than its view carries. + """ + course_id = str(course_key) + legacy_policy = client.get(reverse('grades_api:v1:course_grading_policy', kwargs={'course_id': course_id})) + legacy_info = client.get(reverse('grades_api:v1:course_gradebook_grading_info', kwargs={'course_id': course_id})) + new_default = client.get(course_grading_policy_url(course_key)) + new_minimal = client.get(course_grading_policy_url(course_key, view='minimal')) + for response, members in ((new_default, DEFAULT_MEMBERS), (new_minimal, MINIMAL_MEMBERS)): + if response.status_code == status.HTTP_200_OK: + assert set(response.json()) == members + return legacy_policy.status_code, legacy_info.status_code, new_default.status_code, new_minimal.status_code + + +def assert_views_follow_v1(legacy_policy, legacy_info, new_default, new_minimal): + """ + Fail unless each v2 view admits exactly the callers of the v1 data it serves. + + The default view serves v1's grading information, so it admits whom that + endpoint admits. The minimal view serves v1's policy, which the grading + information also contains, so it admits whom either endpoint admits. + """ + assert new_default == legacy_info + assert (new_minimal == status.HTTP_200_OK) == (status.HTTP_200_OK in (legacy_policy, legacy_info)) + + +class GradingPolicyTestBase(GradeViewTestMixin, APITestCase): + """ + The shared course, plus one subsection of each kind the policy treats differently: + an ungraded one, a staff-only one, and one in a section hidden from the outline. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + chapter = cls.store.get_course(cls.course_key).get_children()[0] + cls.ungraded = BlockFactory.create( + parent_location=chapter.location, category='sequential', display_name='Reading', + ) + BlockFactory.create( + parent_location=chapter.location, category='sequential', graded=True, format='Homework', + visible_to_staff_only=True, display_name='Staff-only homework', + ) + hidden_chapter = BlockFactory.create( + parent_location=cls.course.location, category='chapter', hide_from_toc=True, display_name='Hidden', + ) + BlockFactory.create( + parent_location=hidden_chapter.location, category='sequential', graded=True, format='Homework', + display_name='Homework in a hidden section', + ) + + def setUp(self): + super().setUp() + warm_read_caches(self.course_key) + + def login(self, user): + """Sign ``user`` in with a browser session.""" + self.client.login(username=user.username, password=self.password) + + def user_with_role(self, role, key=None): + """Return a new user holding ``role`` on ``key``, by default the test course.""" + user = UserFactory(password=self.password) + role(key or self.course_key).add_users(user) + return user + + def v1_grading_info_url(self, course_key=None, **query): + url = reverse( + 'grades_api:v1:course_gradebook_grading_info', kwargs={'course_id': str(course_key or self.course_key)}, + ) + return with_query(url, **query) + + def v1_policy_url(self, course_key=None): + return reverse('grades_api:v1:course_grading_policy', kwargs={'course_id': str(course_key or self.course_key)}) + + +@ddt.ddt +class GradingPolicyAccessTest(GradingPolicyTestBase): + """ + The policy admits every caller either v1 endpoint it replaces admits, and nobody else. + + v1 split the policy over two endpoints: one for course staff, and one for + callers who may manage the gradebook. + """ + + def actor(self, kind): + """Return a user holding the named kind of access to the test course.""" + return { + 'learner': lambda: self.student, + 'beta_tester': lambda: self.user_with_role(CourseBetaTesterRole), + 'staff_of_another_course': lambda: self.user_with_role(CourseStaffRole, self.empty_course.id), + 'course_staff': lambda: self.user_with_role(CourseStaffRole), + 'course_instructor': lambda: self.user_with_role(CourseInstructorRole), + 'limited_staff': lambda: self.user_with_role(CourseLimitedStaffRole), + 'org_staff': lambda: self.user_with_role(OrgStaffRole, self.course_key.org), + 'global_staff': lambda: self.global_staff, + }[kind]() + + def statuses(self, masquerading=False): + """ + Return the statuses of the v1 policy, v1 grading information, v2 default + and v2 minimal reads. + """ + with patch('lms.djangoapps.courseware.access.is_masquerading_as_student', return_value=masquerading): + return reads_of_each_policy_address(self.client, self.course_key) + + @ddt.data( + ('learner', False, (403, 403, 403, 403)), + ('beta_tester', False, (403, 403, 403, 403)), + ('staff_of_another_course', False, (403, 403, 403, 403)), + ('course_staff', False, (200, 200, 200, 200)), + ('course_staff', True, (403, 200, 200, 200)), + ('course_instructor', False, (200, 200, 200, 200)), + ('limited_staff', False, (200, 200, 200, 200)), + ('org_staff', False, (200, 200, 200, 200)), + ('global_staff', False, (200, 200, 200, 200)), + ) + @ddt.unpack + def test_each_view_admits_exactly_whom_its_v1_endpoint_admits(self, kind, masquerading, expected): + self.login(self.actor(kind)) + statuses = self.statuses(masquerading) + assert statuses == expected + assert_views_follow_v1(*statuses) + + def test_anonymous_caller_is_refused(self): + assert_error_envelope( + self.client.get(course_grading_policy_url(self.course_key)), + expected_status=401, expected_type_slug='authn', + ) + + def test_refusal_carries_the_error_body(self): + self.login(self.student) + assert_error_envelope( + self.client.get(course_grading_policy_url(self.course_key)), + expected_status=403, expected_type_slug='authz', + ) + + def test_caller_without_access_cannot_learn_whether_a_course_exists(self): + self.login(self.student) + assert_error_envelope( + self.client.get(course_grading_policy_url(MISSING_COURSE_KEY)), + expected_status=403, expected_type_slug='authz', + ) + + def test_missing_course_is_not_found_for_a_caller_with_access(self): + self.login(self.global_staff) + assert_error_envelope( + self.client.get(course_grading_policy_url(MISSING_COURSE_KEY)), + expected_status=404, expected_type_slug='not-found', + ) + + +class GradingPolicyCcxTest(CcxTestCase, APITestCase): + """The grading policy of a CCX course.""" + + def setUp(self): + super().setUp() + self.make_coach() + self.ccx = self.make_ccx() + self.ccx_key = CCXLocator.from_course_locator(self.course.id, str(self.ccx.id)) + + def test_coach_is_admitted(self): + self.client.force_authenticate(self.coach) + response = self.client.get(course_grading_policy_url(self.ccx_key)) + assert response.status_code == status.HTTP_200_OK, response.content + assert {subsection['usage_key'].split(':')[0] for subsection in response.json()['subsections']} == { + 'ccx-block-v1', + } + + def test_outsider_is_refused(self): + self.client.force_authenticate(UserFactory()) + assert_error_envelope( + self.client.get(course_grading_policy_url(self.ccx_key)), expected_status=403, expected_type_slug='authz', + ) + + def test_ccx_staff_keep_the_minimal_view_only_when_custom_courses_are_off(self): + """ + With custom courses off, CCX staff may no longer manage the gradebook, + but they are still course staff, whom v1's policy endpoint admitted. + """ + staff = UserFactory() + CourseStaffRole(self.ccx_key).add_users(staff) + self.client.force_authenticate(staff) + with override_settings(CUSTOM_COURSES_EDX=False): + assert reads_of_each_policy_address(self.client, self.ccx_key) == (200, 403, 403, 200) + + def test_master_organization_staff_keep_the_minimal_view_only_when_custom_courses_are_off(self): + staff = UserFactory() + OrgStaffRole(self.course.id.org).add_users(staff) + self.client.force_authenticate(staff) + with override_settings(CUSTOM_COURSES_EDX=False): + assert reads_of_each_policy_address(self.client, self.ccx_key) == (200, 403, 403, 200) + + +@ddt.ddt +class GradingPolicyCcxAccessTest(CcxTestCase, APITestCase): + """On a CCX course, each view admits exactly the callers of the v1 endpoint whose data it serves.""" + + def setUp(self): + super().setUp() + self.make_coach() + self.ccx = self.make_ccx() + self.ccx_key = CCXLocator.from_course_locator(self.course.id, str(self.ccx.id)) + + def actor(self, kind): + """Return a user holding the named kind of access to the CCX course.""" + if kind == 'coach': + return self.coach + if kind == 'site_staff': + return UserFactory(is_staff=True) + user = UserFactory() + role = { + 'ccx_staff': lambda: CourseStaffRole(self.ccx_key), + 'ccx_instructor': lambda: CourseInstructorRole(self.ccx_key), + 'master_staff': lambda: CourseStaffRole(self.course.id), + 'master_org_staff': lambda: OrgStaffRole(self.course.id.org), + }.get(kind) + if role: + role().add_users(user) + return user + + @ddt.data( + *[(kind, True) for kind in ( + 'coach', 'ccx_staff', 'ccx_instructor', 'master_staff', 'master_org_staff', 'site_staff', 'outsider', + )], + *[(kind, False) for kind in ( + 'coach', 'ccx_staff', 'ccx_instructor', 'master_staff', 'master_org_staff', 'site_staff', 'outsider', + )], + ) + @ddt.unpack + def test_each_view_follows_v1(self, kind, custom_courses): + self.client.force_authenticate(self.actor(kind)) + with override_settings(CUSTOM_COURSES_EDX=custom_courses): + statuses = reads_of_each_policy_address(self.client, self.ccx_key) + assert set(statuses) <= {status.HTTP_200_OK, status.HTTP_403_FORBIDDEN}, statuses + assert_views_follow_v1(*statuses) + + def test_master_course_that_disables_ccx_leaves_its_staff_the_minimal_view_only(self): + master_course = self.store.get_course(self.course.id) + master_course.enable_ccx = False + staff = UserFactory() + OrgStaffRole(self.course.id.org).add_users(staff) + self.client.force_authenticate(staff) + with patch('lms.djangoapps.grades.rest_api.v1.gradebook_views.get_course_by_id', return_value=master_course): + statuses = reads_of_each_policy_address(self.client, self.ccx_key) + assert statuses == (200, 403, 403, 200) + + +class GradingPolicyContentTest(GradingPolicyTestBase): + """What the policy says about the course.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def policy(self, **query): + response = self.client.get(course_grading_policy_url(self.course_key, **query)) + assert response.status_code == status.HTTP_200_OK, response.content + return response.json() + + def test_default_view(self): + body = self.policy() + assert set(body) == { + 'grade_cutoffs', 'assignment_types', 'subsections', 'grades_frozen', 'bulk_management_enabled', + } + assert (body['grade_cutoffs'], body['assignment_types']) == ({'Pass': 0.5}, DEFAULT_ASSIGNMENT_TYPES) + assert (body['grades_frozen'], body['bulk_management_enabled']) == (False, False) + first = body['subsections'][0] + assert first == { + 'usage_key': first['usage_key'], + 'display_name': 'Sequential Homework 0', + 'graded': True, + 'assignment_type': 'Homework', + 'short_label': 'HW 01', + } + assert body['subsections'][-1] == { + 'usage_key': str(self.ungraded.location), + 'display_name': 'Reading', + 'graded': False, + 'assignment_type': None, + 'short_label': None, + } + + def test_staff_only_and_hidden_subsections_are_left_out(self): + names = [subsection['display_name'] for subsection in self.policy()['subsections']] + assert len(names) == GRADED_SUBSECTIONS + 1 + assert 'Staff-only homework' not in names + assert 'Homework in a hidden section' not in names + + def test_graded_only(self): + subsections = self.policy(graded_only='true')['subsections'] + assert len(subsections) == GRADED_SUBSECTIONS + assert {subsection['graded'] for subsection in subsections} == {True} + + def test_minimal_view(self): + assert self.policy(view='minimal') == { + 'grade_cutoffs': {'Pass': 0.5}, 'assignment_types': DEFAULT_ASSIGNMENT_TYPES, + } + + def test_minimal_view_reads_the_course_one_level_deep(self): + with patch('lms.djangoapps.grades.rest_api.v2.services.get_course_by_id', wraps=get_course_by_id) as get: + self.policy(view='minimal') + assert get.call_args.kwargs == {'depth': 0} + + def test_bad_query_values_are_refused(self): + for query, parameter in (({'view': 'full'}, 'view'), ({'graded_only': 'perhaps'}, 'graded_only')): + response = self.client.get(course_grading_policy_url(self.course_key, **query)) + body = assert_error_envelope(response, expected_status=400, expected_type_slug='validation') + assert list(body['errors']) == [parameter] + + def test_bulk_management_follows_the_masters_track(self): + CourseModeFactory(course_id=self.course_key, mode_slug='masters') + assert self.policy()['bulk_management_enabled'] is True + + def test_bulk_management_follows_its_flag(self): + with override_waffle_flag(BULK_MANAGEMENT, active=True): + assert self.policy()['bulk_management_enabled'] is True + + def test_course_without_content_is_not_found(self): + with patch('lms.djangoapps.grades.rest_api.v2.services.get_course_by_id', side_effect=Http404): + response = self.client.get(course_grading_policy_url(self.course_key)) + assert_error_envelope(response, expected_status=404, expected_type_slug='not-found') + + +@ddt.ddt +class GradingPolicyFreezeTest(GradingPolicyTestBase): + """Whether grades are frozen agrees with the shared rule on every side of it.""" + + @ddt.data( + (True, -60, True), + (True, 60, False), + (True, None, False), + (False, -60, False), + ) + @ddt.unpack + def test_agrees_with_the_shared_rule(self, enforced, end_in_days, frozen): + end = None if end_in_days is None else timezone.now() + timedelta(days=end_in_days) + with override_waffle_flag(ENFORCE_FREEZE_GRADE_AFTER_COURSE_END, active=enforced), patch( + 'lms.djangoapps.grades.grade_utils.CourseOverview.get_from_id', return_value=MagicMock(end=end), + ): + assert (grades_frozen(self.course_key, end), are_grades_frozen(self.course_key)) == (frozen, frozen) + + +class GradingPolicyFrozenCourseTest(GradingPolicyTestBase): + """The policy of a course that ended long ago reports its grades frozen.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + course = cls.store.get_course(cls.course_key) + course.end = timezone.now() - timedelta(days=60) + cls.store.update_item(course, ModuleStoreEnum.UserID.test) + + def test_frozen_only_where_freezing_is_switched_on(self): + self.client.force_authenticate(self.global_staff) + url = course_grading_policy_url(self.course_key) + with override_waffle_flag(ENFORCE_FREEZE_GRADE_AFTER_COURSE_END, active=True): + assert self.client.get(url).json()['grades_frozen'] is True + with override_waffle_flag(ENFORCE_FREEZE_GRADE_AFTER_COURSE_END, active=False): + assert self.client.get(url).json()['grades_frozen'] is False + + +class GradingPolicyParityTest(GradingPolicyTestBase): + """The v2 policy equals the two v1 bodies it replaces, apart from the declared differences.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def test_default_view_against_grading_information(self): + legacy = self.client.get(self.v1_grading_info_url()).json() + new = self.client.get(course_grading_policy_url(self.course_key)).json() + assert list(legacy['assignment_types']) == [entry['type'] for entry in new['assignment_types']] + legacy['assignment_types'] = list(legacy['assignment_types'].values()) + assert_parity( + legacy, new, + renames={'module_id': 'usage_key', 'can_see_bulk_management': 'bulk_management_enabled'}, + ) + + def test_graded_only_against_grading_information(self): + legacy = self.client.get(self.v1_grading_info_url(graded_only='true')).json() + new = self.client.get(course_grading_policy_url(self.course_key, graded_only='true')).json() + assert [s['module_id'] for s in legacy['subsections']] == [s['usage_key'] for s in new['subsections']] + + def test_minimal_view_against_the_policy(self): + legacy = self.client.get(self.v1_policy_url()).json() + new = self.client.get(course_grading_policy_url(self.course_key, view='minimal')).json() + assert_parity( + {'assignment_types': legacy}, new, + renames={'assignment_type': 'type', 'count': 'min_count', 'dropped': 'drop_count'}, + differences=[ + ('grade_cutoffs', 'the minimal view adds the grade cutoffs'), + ('assignment_types[*].short_label', 'each assignment type carries its short label'), + ], + ) + + +class GradingPolicyUnnamedSubsectionTest(GradingPolicyTestBase): + """A subsection the course gives no display name is listed with a null name, as v1 lists it.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + chapter = cls.store.get_course(cls.course_key).get_children()[0] + unnamed = BlockFactory.create(parent_location=chapter.location, category='sequential', display_name=None) + cls.unnamed_key = str(unnamed.location) + + def test_display_name_is_null(self): + self.client.force_authenticate(self.global_staff) + new = self.client.get(course_grading_policy_url(self.course_key)).json() + legacy = self.client.get(self.v1_grading_info_url()).json() + assert [s['display_name'] for s in new['subsections'] if s['usage_key'] == self.unnamed_key] == [None] + assert [s['display_name'] for s in legacy['subsections'] if s['module_id'] == self.unnamed_key] == [None] + + +class GradingPolicyReadOnlyTest(ReadOnlyRequestMixin, GradingPolicyTestBase): + """Reading the policy changes nothing and announces nothing.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def test_each_view(self): + with self.assert_nothing_is_written(): + for query in ({}, {'view': 'minimal'}, {'graded_only': 'true'}): + assert self.client.get(course_grading_policy_url(self.course_key, **query)).status_code == 200 + assert self.client.head(course_grading_policy_url(self.course_key)).status_code == 200 + + def test_course_without_an_overview_is_not_created_one(self): + """ + A course whose overview is missing is still read from its content, and + no overview is created; v1's grading information creates one. + """ + CourseOverview.objects.filter(id=self.course_key).delete() + with self.assert_nothing_is_written(): + response = self.client.get(course_grading_policy_url(self.course_key)) + assert response.status_code == status.HTTP_200_OK + assert response.json()['assignment_types'] == DEFAULT_ASSIGNMENT_TYPES + assert not CourseOverview.objects.filter(id=self.course_key).exists() + assert self.client.get(self.v1_grading_info_url()).status_code == status.HTTP_200_OK + assert CourseOverview.objects.filter(id=self.course_key).exists() + + +class GradingPolicyCohortAssignmentTest(CohortedCourseMixin, GradingPolicyTestBase): + """On a course whose content groups follow cohorts, reading the policy assigns nobody to a cohort.""" + + def test_each_view(self): + self.client.force_authenticate(self.global_staff) + for query in ({}, {'view': 'minimal'}, {'graded_only': 'true'}): + with capture_cohort_assignment() as record: + response = self.client.get(course_grading_policy_url(self.course_key, **query)) + assert response.status_code == status.HTTP_200_OK + assert (record.tables, record.memberships, record.tracking) == ([], [], []) + assert self.cohort_of(self.uncohorted) is None + + +class GradingPolicyQueryCountTest(GradingPolicyTestBase): + """ + The queries each view issues, against the v1 read it replaces. Each address is requested once first. + + The default view issues two more than v1's grading information: v1 checks + no course existence, and reads the course modes through a cached lookup + that the first request fills, where v2 queries them directly. + """ + + #: Measured on this fixture; a change is a change in the data path. + DEFAULT_QUERIES = 14 + V1_GRADING_INFO_QUERIES = 12 + MINIMAL_QUERIES = 9 + V1_POLICY_QUERIES = 9 + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def measure(self, url): + assert self.client.get(url).status_code == status.HTTP_200_OK + response, count = count_queries(self.client, url) + assert response.status_code == status.HTTP_200_OK + return count + + def test_default_view(self): + legacy = self.measure(self.v1_grading_info_url()) + new = self.measure(course_grading_policy_url(self.course_key)) + assert (new, legacy) == (self.DEFAULT_QUERIES, self.V1_GRADING_INFO_QUERIES) + + def test_minimal_view(self): + legacy = self.measure(self.v1_policy_url()) + new = self.measure(course_grading_policy_url(self.course_key, view='minimal')) + assert (new, legacy) == (self.MINIMAL_QUERIES, self.V1_POLICY_QUERIES) + + +@override_settings(DEBUG=False) +class GradingPolicyUrlTest(GradingPolicyTestBase): + """The address, its name, and the not-found body for addresses no route matches.""" + + COURSE_KEY = 'course-v1:edX+DemoX+Demo_Course' + + def test_reverse_literal(self): + assert reverse('grade_v2:course_grading_policy', kwargs={'course_key': self.COURSE_KEY}) == ( + f'/api/grade/v2/courses/{self.COURSE_KEY}/grading_policy/' + ) + + def test_unmatched_addresses_get_a_json_not_found(self): + self.client.force_authenticate(self.global_staff) + for path in ( + '/api/grade/v2/courses/not-a-course-key/grading_policy/', + '/api/grade/v2/courses/edX/DemoX/Demo/grading_policy/', + f'/api/grade/v2/courses/{self.course_key}/grading_policy/extra/', + ): + response = self.client.get(path) + assert response['Content-Type'] == 'application/json', path + assert_error_envelope(response, expected_status=404, expected_type_slug='not-found') diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py b/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py new file mode 100644 index 000000000000..55c0d4a58a51 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py @@ -0,0 +1,808 @@ +"""Tests for the gradebook endpoints of the Grades API v2.""" + +from datetime import datetime +from unittest.mock import patch + +import ddt +import pytest +from ccx_keys.locator import CCXLocator +from django.http import Http404 +from django.test.utils import override_settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.locator import BlockUsageLocator +from pytz import UTC +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.roles import ( + CourseBetaTesterRole, + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole, +) +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.ccx.tests.utils import CcxTestCase +from lms.djangoapps.grades.config.waffle import WRITABLE_GRADEBOOK +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory +from lms.djangoapps.grades.models import ( + BlockRecord, + BlockRecordList, + PersistentCourseGrade, + PersistentSubsectionGrade, +) +from lms.djangoapps.grades.rest_api.v1.tests.mixins import GradeViewTestMixin +from lms.djangoapps.grades.rest_api.v2.tests.mixins import ( + PAGE_FIELDS, + CohortedCourseMixin, + ReadOnlyRequestMixin, + capture_cohort_assignment, + count_queries, + gradebook_entry_detail_url, + gradebook_entry_list_url, + usernames_in, + warm_cohort_settings, + warm_read_caches, + with_query, +) +from lms.djangoapps.grades.rest_api.v2.tests.parity import assert_parity +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.course_groups.models import CourseCohortsSettings +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory + +#: Every learner the shared fixture enrolls in the test course, in enrollment order. +FIXTURE_LEARNERS = ['student', 'other_student', 'program_student', 'program_masters_student'] + +#: Number of graded subsections in the shared test course. +GRADED_SUBSECTIONS = 26 + +MISSING_COURSE_KEY = 'course-v1:NoSuch+Course+Run' + + +class GradebookTestBase(GradeViewTestMixin, APITestCase): + """Fixtures shared by the gradebook endpoint tests.""" + + def setUp(self): + super().setUp() + CourseOverview.objects.filter(id=self.course_key).update(org=self.course_key.org) + warm_read_caches(self.course_key) + warm_cohort_settings(self.course_key) + self.homework = self.store.get_course(self.course_key).get_children()[0].get_children()[0] + + def login(self, user): + """Sign ``user`` in with a browser session.""" + self.client.login(username=user.username, password=self.password) + + def user_with_role(self, role): + """Return a new user holding ``role`` on the test course.""" + user = UserFactory(password=self.password) + role(self.course_key).add_users(user) + return user + + def store_subsection_grade(self, user, earned_graded, possible_graded): + """Store an attempted grade on the first homework subsection for ``user``.""" + location = BlockUsageLocator(self.course_key, 'problem', 'problem_for_grade') + PersistentSubsectionGrade.update_or_create_grade( + user_id=user.id, + usage_key=self.homework.location, + course_version='', + subtree_edited_timestamp=datetime(2020, 1, 1, tzinfo=UTC), + earned_all=earned_graded, + possible_all=possible_graded, + earned_graded=earned_graded, + possible_graded=possible_graded, + visible_blocks=BlockRecordList( + [BlockRecord(locator=location, weight=1, raw_possible=possible_graded, graded=True)], + self.course_key, + ), + first_attempted=datetime(2020, 1, 2, tzinfo=UTC), + ) + + def store_course_grade(self, user, percent): + """Store a course grade of ``percent`` for ``user``.""" + PersistentCourseGrade.objects.create( + user_id=user.id, course_id=self.course_key, percent_grade=percent, + letter_grade='Pass' if percent >= 0.5 else '', grading_policy_hash='policy-hash', + ) + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +@ddt.ddt +class GradebookAccessTest(GradebookTestBase): + """Who may read a gradebook while its course has the writable gradebook switched on.""" + + def test_anonymous_caller_is_refused(self): + for url in (gradebook_entry_list_url(self.course_key), gradebook_entry_detail_url(self.course_key, 'student')): + assert_error_envelope( + self.client.get(url), expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug='authn', + ) + + @ddt.data('student', 'other_student') + def test_learners_are_refused(self, username): + self.login(getattr(self, username)) + for url in (gradebook_entry_list_url(self.course_key), gradebook_entry_detail_url(self.course_key, 'student')): + assert_error_envelope( + self.client.get(url), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz', + ) + + @ddt.data(CourseStaffRole, CourseInstructorRole, CourseLimitedStaffRole) + def test_course_team_is_admitted(self, role): + self.login(self.user_with_role(role)) + response = self.client.get(gradebook_entry_list_url(self.course_key)) + assert response.status_code == status.HTTP_200_OK + assert usernames_in(response) == FIXTURE_LEARNERS + + def test_global_staff_are_admitted(self): + self.login(self.global_staff) + response = self.client.get(gradebook_entry_detail_url(self.course_key, 'student')) + assert response.status_code == status.HTTP_200_OK + assert response.json()['username'] == 'student' + + def test_staff_of_another_course_are_refused(self): + other_staff = UserFactory(password=self.password) + CourseStaffRole(self.empty_course.id).add_users(other_staff) + self.login(other_staff) + assert_error_envelope( + self.client.get(gradebook_entry_list_url(self.course_key)), + expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz', + ) + + def test_caller_without_access_cannot_learn_whether_a_course_exists(self): + self.login(self.student) + assert_error_envelope( + self.client.get(gradebook_entry_list_url(MISSING_COURSE_KEY)), + expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz', + ) + + def test_missing_course_is_not_found_for_a_caller_with_access(self): + self.login(self.global_staff) + assert_error_envelope( + self.client.get(gradebook_entry_list_url(MISSING_COURSE_KEY)), + expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found', + ) + + def test_learner_not_enrolled_is_not_found(self): + self.login(self.global_staff) + assert_error_envelope( + self.client.get(gradebook_entry_detail_url(self.course_key, self.global_staff.username)), + expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found', + ) + + def test_deactivated_course_staff_session_is_refused(self): + """ + A deactivated account's browser session does not authenticate. v1's + gradebook authenticated it, then refused it with 403 because the course + roles of a deactivated account do not count. + """ + course_staff = self.user_with_role(CourseStaffRole) + course_staff.is_active = False + course_staff.save() + self.login(course_staff) + legacy = self.client.get(reverse('grades_api:v1:course_gradebook', kwargs={'course_id': str(self.course_key)})) + assert legacy.status_code == status.HTTP_403_FORBIDDEN + assert_error_envelope( + self.client.get(gradebook_entry_list_url(self.course_key)), + expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug='authn', + ) + + def test_active_course_staff_session_is_admitted(self): + self.login(self.user_with_role(CourseStaffRole)) + assert usernames_in(self.client.get(gradebook_entry_list_url(self.course_key))) == FIXTURE_LEARNERS + + def test_inactive_enrollment_is_readable_but_not_listed(self): + CourseEnrollment.objects.filter(user=self.student, course_id=self.course_key).update(is_active=False) + self.login(self.global_staff) + assert 'student' not in usernames_in(self.client.get(gradebook_entry_list_url(self.course_key))) + response = self.client.get(gradebook_entry_detail_url(self.course_key, 'student')) + assert response.status_code == status.HTTP_200_OK + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=False) +class GradebookDisabledTest(GradebookTestBase): + """A course with the writable gradebook switched off.""" + + def test_caller_with_access_is_refused_with_the_feature_type(self): + self.login(self.global_staff) + for url in (gradebook_entry_list_url(self.course_key), gradebook_entry_detail_url(self.course_key, 'student')): + assert_error_envelope( + self.client.get(url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug='grades/writable-gradebook-disabled', + ) + + def test_caller_without_access_cannot_learn_the_setting(self): + self.login(self.student) + assert_error_envelope( + self.client.get(gradebook_entry_list_url(self.course_key)), + expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz', + ) + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +@ddt.ddt +class GradebookCcxAccessTest(CcxTestCase, APITestCase): + """Who may read the gradebook of a CCX course.""" + + def setUp(self): + super().setUp() + self.make_coach() + self.ccx = self.make_ccx() + self.ccx_key = CCXLocator.from_course_locator(self.course.id, str(self.ccx.id)) + self.learner = UserFactory() + CourseEnrollmentFactory(user=self.learner, course_id=self.ccx_key) + + def actor(self, kind): + """Return a user holding the named kind of access to ``self.ccx_key``.""" + if kind == 'coach': + return self.coach + if kind == 'site_staff': + return UserFactory(is_staff=True) + user = UserFactory() + if kind == 'ccx_staff': + CourseStaffRole(self.ccx_key).add_users(user) + elif kind == 'ccx_instructor': + CourseInstructorRole(self.ccx_key).add_users(user) + return user + + @ddt.data('coach', 'ccx_staff', 'ccx_instructor', 'site_staff') + def test_ccx_team_is_admitted(self, kind): + self.client.force_authenticate(self.actor(kind)) + response = self.client.get(gradebook_entry_list_url(self.ccx_key)) + assert response.status_code == status.HTTP_200_OK + assert usernames_in(response) == [self.learner.username] + + def test_outsider_is_refused(self): + self.client.force_authenticate(self.actor('outsider')) + assert_error_envelope( + self.client.get(gradebook_entry_list_url(self.ccx_key)), + expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz', + ) + + def test_coach_is_refused_when_custom_courses_are_off(self): + self.client.force_authenticate(self.coach) + with override_settings(CUSTOM_COURSES_EDX=False): + response = self.client.get(gradebook_entry_list_url(self.ccx_key)) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz') + + def test_coach_is_refused_when_the_master_course_disables_ccx(self): + master_course = self.store.get_course(self.course.id) + master_course.enable_ccx = False + self.client.force_authenticate(self.coach) + with patch( + 'lms.djangoapps.grades.rest_api.v1.gradebook_views.get_course_by_id', return_value=master_course, + ): + response = self.client.get(gradebook_entry_list_url(self.ccx_key)) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz') + + def test_coach_reads_one_learners_row(self): + self.client.force_authenticate(self.coach) + response = self.client.get(gradebook_entry_detail_url(self.ccx_key, self.learner.username)) + assert response.status_code == status.HTTP_200_OK + assert response.json()['username'] == self.learner.username + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class GradebookRowTest(GradebookTestBase): + """The content of the rows.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def test_rows_carry_the_learner_and_every_graded_subsection(self): + self.store_course_grade(self.student, 0.85) + self.store_subsection_grade(self.student, 6.0, 8.0) + rows = self.client.get(gradebook_entry_list_url(self.course_key)).json()['results'] + assert [row['username'] for row in rows] == FIXTURE_LEARNERS + student = rows[0] + assert set(student) == {'username', 'email', 'percent', 'section_breakdown'} + assert (student['email'], student['percent']) == ('', 0.85) + assert len(student['section_breakdown']) == GRADED_SUBSECTIONS + assert student['section_breakdown'][0] == { + 'attempted': True, + 'category': 'Homework', + 'label': 'HW 01', + 'usage_key': str(self.homework.location), + 'percent': 0.75, + 'score_earned': 6.0, + 'score_possible': 8.0, + 'subsection_name': self.homework.display_name, + } + assert student['section_breakdown'][1]['attempted'] is False + assert (student['section_breakdown'][1]['score_earned'], student['section_breakdown'][1]['label']) == ( + 0, 'HW 02', + ) + + def test_every_row_numbers_its_subsections_from_one(self): + rows = self.client.get(gradebook_entry_list_url(self.course_key)).json()['results'] + assert {row['section_breakdown'][0]['label'] for row in rows} == {'HW 01'} + assert {row['section_breakdown'][-1]['label'] for row in rows} == {'Final 01'} + + def rows_by_username(self): + """Return the rows of the first gradebook page, keyed by username.""" + results = self.client.get(gradebook_entry_list_url(self.course_key)).json()['results'] + return {row['username']: row for row in results} + + def test_program_learners_carry_their_program_key(self): + rows = self.rows_by_username() + assert rows['program_student']['external_user_key'] == 'program_user_key_0' + assert 'external_user_key' not in rows['student'] + + def test_masters_learners_carry_their_name_and_email(self): + rows = self.rows_by_username() + masters = rows['program_masters_student'] + assert (masters['email'], masters['full_name']) == ( + self.program_masters_student.email, self.program_masters_student.profile.name, + ) + assert 'full_name' not in rows['program_student'] + + def test_minimal_view(self): + self.store_course_grade(self.student, 0.85) + response = self.client.get(gradebook_entry_list_url(self.course_key, view='minimal')) + assert response.json()['results'] == [ + {'username': 'student', 'percent': 0.85}, + {'username': 'other_student', 'percent': 0.0}, + {'username': 'program_student', 'percent': 0.0}, + {'username': 'program_masters_student', 'percent': 0.0}, + ] + detail = self.client.get(gradebook_entry_detail_url(self.course_key, 'student', view='minimal')) + assert detail.json() == {'username': 'student', 'percent': 0.85} + + def test_unknown_view_is_refused(self): + for url in ( + gradebook_entry_list_url(self.course_key, view='full'), + gradebook_entry_detail_url(self.course_key, 'student', view='full'), + ): + body = assert_error_envelope( + self.client.get(url), expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='validation', + ) + assert list(body['errors']) == ['view'] + + def test_learner_whose_grade_cannot_be_computed_is_left_out_of_the_page(self): + real_read = CourseGradeFactory.read + + def read(factory, user, *args, **kwargs): + if user.username == 'other_student': + raise RuntimeError('cannot grade') + return real_read(factory, user, *args, **kwargs) + + with patch.object(CourseGradeFactory, 'read', autospec=True, side_effect=read): + response = self.client.get(gradebook_entry_list_url(self.course_key)) + assert usernames_in(response) == ['student', 'program_student', 'program_masters_student'] + + def test_row_that_cannot_be_computed_is_a_server_error(self): + with patch.object(CourseGradeFactory, 'read', side_effect=RuntimeError('boom')): + response = self.client.get(gradebook_entry_detail_url(self.course_key, 'student')) + body = assert_error_envelope( + response, expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, expected_type_slug='internal', + ) + assert 'boom' not in body['detail'] + + def test_course_without_content_is_not_found(self): + with patch('lms.djangoapps.grades.rest_api.v2.services.get_course_by_id', side_effect=Http404): + response = self.client.get(gradebook_entry_list_url(self.course_key)) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + def test_page_caches_the_cohorts_of_its_learners(self): + """ + Caching every learner's cohort up front keeps grading from assigning + learners to cohorts one by one while the page is read. + """ + with patch('lms.djangoapps.grades.rest_api.v2.services.cohorts.bulk_cache_cohorts') as bulk_cache: + self.client.get(gradebook_entry_list_url(self.course_key)) + (course_key, users), _ = bulk_cache.call_args + assert (course_key, [user.username for user in users]) == (self.course_key, FIXTURE_LEARNERS) + + def test_paging(self): + for index in range(8): + CourseEnrollmentFactory(user=UserFactory(username=f'extra_{index}'), course_id=self.course_key) + body = self.client.get(gradebook_entry_list_url(self.course_key, page_size=5, page=3)).json() + assert set(body) == PAGE_FIELDS + assert (body['count'], body['num_pages'], body['current_page'], body['start']) == (12, 3, 3, 10) + assert [row['username'] for row in body['results']] == ['extra_6', 'extra_7'] + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +@ddt.ddt +class GradebookFilterTest(GradebookTestBase): + """Each filter narrows the gradebook, and bad values are refused.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + self.store_course_grade(self.student, 0.85) + self.store_course_grade(self.other_student, 0.45) + self.store_subsection_grade(self.student, 6.0, 8.0) + self.store_subsection_grade(self.other_student, 2.0, 8.0) + + def usernames(self, **query): + """Return the usernames on the first gradebook page for ``query``.""" + response = self.client.get(gradebook_entry_list_url(self.course_key, **query)) + assert response.status_code == status.HTTP_200_OK, response.content + return usernames_in(response) + + def refused_parameters(self, **query): + """Return the names of the parameters a 400 response blames.""" + response = self.client.get(gradebook_entry_list_url(self.course_key, **query)) + body = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='validation', + ) + return sorted(body['errors']) + + @ddt.data( + ('program_user_key_0', ['program_student', 'program_masters_student']), + ('i_like', ['other_student']), + ('MASTERS', ['program_masters_student']), + ) + @ddt.unpack + def test_user_contains(self, text, expected): + assert self.usernames(user_contains=text) == expected + + def test_username_contains(self): + assert self.usernames(username_contains='other') == ['other_student'] + + def test_cohort_id(self): + cohort = CohortFactory(course_id=self.course_key, users=[self.student, self.program_student]) + assert self.usernames(cohort_id=cohort.id) == ['student', 'program_student'] + + def test_cohort_of_another_course_matches_nobody(self): + CourseEnrollmentFactory(user=self.student, course_id=self.empty_course.id) + cohort = CohortFactory(course_id=self.empty_course.id, users=[self.student]) + assert self.usernames(cohort_id=cohort.id) == [] + + def test_enrollment_mode(self): + assert self.usernames(enrollment_mode='masters') == ['program_masters_student'] + + def test_excluded_course_roles(self): + CourseBetaTesterRole(self.course_key).add_users(self.student) + CourseStaffRole(self.course_key).add_users(self.other_student) + CourseStaffRole(self.empty_course.id).add_users(self.program_student) + assert self.usernames(excluded_course_roles='beta_testers') == [ + 'other_student', 'program_student', 'program_masters_student', + ] + assert self.usernames(excluded_course_roles=['beta_testers', 'staff']) == [ + 'program_student', 'program_masters_student', + ] + assert self.usernames(excluded_course_roles='all') == ['program_student', 'program_masters_student'] + assert self.usernames(excluded_course_roles=['staff', 'all']) == ['program_student', 'program_masters_student'] + assert self.usernames(excluded_course_roles='') == FIXTURE_LEARNERS + + @ddt.data( + ({'assignment_grade_min': 70}, ['student']), + ({'assignment_grade_max': 50}, ['other_student']), + ({'assignment_grade_min': 20, 'assignment_grade_max': 80}, ['student', 'other_student']), + ({'assignment_grade_min': 80}, []), + ({}, FIXTURE_LEARNERS), + ) + @ddt.unpack + def test_assignment_grade(self, bounds, expected): + assert self.usernames(assignment_usage_key=str(self.homework.location), **bounds) == expected + + def test_assignment_bounds_without_an_assignment_have_no_effect(self): + assert self.usernames(assignment_grade_min=70) == FIXTURE_LEARNERS + + @ddt.data( + ({'course_grade_min': 50}, ['student']), + ({'course_grade_min': 0}, FIXTURE_LEARNERS), + ({'course_grade_max': 50}, ['other_student', 'program_student', 'program_masters_student']), + ({'course_grade_min': 40, 'course_grade_max': 50}, ['other_student']), + ) + @ddt.unpack + def test_course_grade(self, bounds, expected): + assert self.usernames(**bounds) == expected + + @ddt.data( + ({'assignment_usage_key': 'not-a-key', 'assignment_grade_min': 1}, ['assignment_usage_key']), + ({'assignment_grade_min': 'abc'}, ['assignment_grade_min']), + ({'course_grade_min': 'abc'}, ['course_grade_min']), + ({'course_grade_max': 'abc'}, ['course_grade_max']), + ({'cohort_id': 'abc'}, ['cohort_id']), + ({'cohort_id': '1.5'}, ['cohort_id']), + ({'ordering': 'bogus'}, ['ordering']), + ) + @ddt.unpack + def test_bad_values_are_refused(self, query, expected): + assert self.refused_parameters(**query) == expected + + @ddt.data( + ('username', ['other_student', 'program_masters_student', 'program_student', 'student']), + ('-id', list(reversed(FIXTURE_LEARNERS))), + ) + @ddt.unpack + def test_ordering(self, ordering, expected): + assert self.usernames(ordering=ordering) == expected + + def test_filters_combine(self): + assert self.usernames(course_grade_max=50, user_contains='program') == [ + 'program_student', 'program_masters_student', + ] + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class GradebookParityTest(GradebookTestBase): + """ + The v2 bodies equal the v1 bodies they replace, apart from the declared differences, + and each filter selects the same learners in both. + """ + + ROW_DIFFERENCES = [ + ('results[*].user_id', 'database ids are not published'), + ] + ENVELOPE_DIFFERENCES = [ + ('count', 'page-number envelope replaces the cursor envelope'), + ('num_pages', 'page-number envelope replaces the cursor envelope'), + ('current_page', 'page-number envelope replaces the cursor envelope'), + ('start', 'page-number envelope replaces the cursor envelope'), + ('total_users_count', 'only the seven page members are returned; count is the filtered total'), + ('filtered_users_count', 'only the seven page members are returned; count is the filtered total'), + ] + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + self.store_course_grade(self.student, 0.85) + self.store_course_grade(self.other_student, 0.45) + self.store_subsection_grade(self.student, 6.0, 8.0) + + def v1_url(self, **query): + url = reverse('grades_api:v1:course_gradebook', kwargs={'course_id': str(self.course_key)}) + return with_query(url, **query) + + def test_gradebook(self): + legacy = self.client.get(self.v1_url()) + new = self.client.get(gradebook_entry_list_url(self.course_key)) + assert legacy.status_code == new.status_code == status.HTTP_200_OK + assert_parity( + legacy.json(), new.json(), + renames={'module_id': 'usage_key'}, + differences=[*self.ROW_DIFFERENCES, *self.ENVELOPE_DIFFERENCES], + ) + + def test_one_learner(self): + legacy = self.client.get(self.v1_url(username='student')) + new = self.client.get(gradebook_entry_detail_url(self.course_key, 'student')) + assert legacy.status_code == new.status_code == status.HTTP_200_OK + assert_parity( + legacy.json(), new.json(), + renames={'module_id': 'usage_key'}, + differences=[('user_id', 'database ids are not published')], + ) + + def test_one_masters_learner(self): + legacy = self.client.get(self.v1_url(username='program_masters_student')) + new = self.client.get(gradebook_entry_detail_url(self.course_key, 'program_masters_student')) + assert_parity( + legacy.json(), new.json(), + renames={'module_id': 'usage_key'}, + differences=[ + ('user_id', 'database ids are not published'), + ('email', "v1 hid the master's-track email on the single-learner address only"), + ], + ) + + def test_each_filter_selects_the_same_learners(self): + cohort = CohortFactory(course_id=self.course_key, users=[self.student, self.program_student]) + CourseBetaTesterRole(self.course_key).add_users(self.student) + homework = str(self.homework.location) + pairs = [ + ({'user_contains': 'program'}, {'user_contains': 'program'}), + ({'username_contains': 'other'}, {'username_contains': 'other'}), + ({'cohort_id': cohort.id}, {'cohort_id': cohort.id}), + ({'enrollment_mode': 'masters'}, {'enrollment_mode': 'masters'}), + ({'excluded_course_roles': 'beta_testers'}, {'excluded_course_roles': 'beta_testers'}), + ({'excluded_course_roles': 'all'}, {'excluded_course_roles': 'all'}), + ( + {'assignment': homework, 'assignment_grade_min': 70}, + {'assignment_usage_key': homework, 'assignment_grade_min': 70}, + ), + ({'course_grade_min': 50}, {'course_grade_min': 50}), + ({'course_grade_min': 0}, {'course_grade_min': 0}), + ({'course_grade_max': 50}, {'course_grade_max': 50}), + ] + for legacy_query, new_query in pairs: + legacy = [row['username'] for row in self.client.get(self.v1_url(**legacy_query)).json()['results']] + new = usernames_in(self.client.get(gradebook_entry_list_url(self.course_key, **new_query))) + assert new == legacy, (legacy_query, legacy, new) + assert legacy != FIXTURE_LEARNERS or new_query == {'course_grade_min': 0}, ( + f'{legacy_query} does not narrow the fixture, so it proves nothing' + ) + + def test_status_codes_that_changed(self): + """Pins the v1 and v2 statuses where the plan declares they differ.""" + self.client.force_authenticate(self.student) + missing_v1 = reverse('grades_api:v1:course_gradebook', kwargs={'course_id': MISSING_COURSE_KEY}) + assert self.client.get(missing_v1).status_code == status.HTTP_404_NOT_FOUND + assert self.client.get(gradebook_entry_list_url(MISSING_COURSE_KEY)).status_code == 403 + self.client.force_authenticate(self.global_staff) + with pytest.raises(ValueError, match='could not convert string to float'): + self.client.get(self.v1_url(course_grade_min='abc')) + assert self.client.get(gradebook_entry_list_url(self.course_key, course_grade_min='abc')).status_code == 400 + CourseEnrollmentFactory(user=self.student, course_id=self.empty_course.id) + other_course_cohort = CohortFactory(course_id=self.empty_course.id, users=[self.student]) + for cohort_id in (other_course_cohort.id, other_course_cohort.id + 1000): + assert self.client.get(self.v1_url(cohort_id=cohort_id)).status_code == status.HTTP_404_NOT_FOUND + response = self.client.get(gradebook_entry_list_url(self.course_key, cohort_id=cohort_id)) + assert response.status_code == status.HTTP_200_OK + assert (response.json()['count'], response.json()['results']) == (0, []) + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class GradebookReadOnlyTest(ReadOnlyRequestMixin, GradebookTestBase): + """Reading a gradebook changes nothing and announces nothing.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + self.store_subsection_grade(self.student, 6.0, 8.0) + + def test_list(self): + with self.assert_nothing_is_written(): + assert self.client.get(gradebook_entry_list_url(self.course_key)).status_code == 200 + + def test_list_minimal_view(self): + with self.assert_nothing_is_written(): + assert self.client.get(gradebook_entry_list_url(self.course_key, view='minimal')).status_code == 200 + + def test_detail(self): + with self.assert_nothing_is_written(): + assert self.client.get(gradebook_entry_detail_url(self.course_key, 'student')).status_code == 200 + + def test_head(self): + with self.assert_nothing_is_written(): + assert self.client.head(gradebook_entry_list_url(self.course_key)).status_code == 200 + assert self.client.head(gradebook_entry_detail_url(self.course_key, 'student')).status_code == 200 + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class GradebookCohortAssignmentTest(CohortedCourseMixin, GradebookTestBase): + """ + On a course whose content groups follow cohorts, reading the gradebook + assigns no learner to a cohort, including a learner who is in none. + + The page looks up every learner's cohort in one pass before grading, so the + per-learner content-group lookup finds an answer and never assigns one. + """ + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def assert_not_assigned(self, url): + with capture_cohort_assignment() as record: + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + assert (record.tables, record.memberships, record.tracking) == ([], [], []) + assert self.cohort_of(self.uncohorted) is None + return response + + def test_list(self): + response = self.assert_not_assigned(gradebook_entry_list_url(self.course_key)) + assert 'uncohorted' in usernames_in(response) + + def test_list_minimal_view(self): + response = self.assert_not_assigned(gradebook_entry_list_url(self.course_key, view='minimal')) + assert 'uncohorted' in usernames_in(response) + + def test_detail(self): + response = self.assert_not_assigned(gradebook_entry_detail_url(self.course_key, 'uncohorted')) + assert response.json()['username'] == 'uncohorted' + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class GradebookColdCohortSettingsTest(GradeViewTestMixin, APITestCase): + """ + The first gradebook read of a course copies its cohort settings into the database. + + Reading cohorts is what keeps the page from assigning learners to cohorts + one by one, and the shared cohort code copies a course's settings the first + time anything reads them. v1 behaves the same. This pins the behaviour so a + change to either side is deliberate. + """ + + def setUp(self): + super().setUp() + warm_read_caches(self.course_key) + self.client.force_authenticate(self.global_staff) + + def test_first_read_copies_the_settings_and_later_reads_write_nothing(self): + assert not CourseCohortsSettings.objects.filter(course_id=self.course_key).exists() + assert self.client.get(gradebook_entry_list_url(self.course_key)).status_code == status.HTTP_200_OK + assert CourseCohortsSettings.objects.filter(course_id=self.course_key).count() == 1 + + def test_v1_does_the_same(self): + url = reverse('grades_api:v1:course_gradebook', kwargs={'course_id': str(self.course_key)}) + assert self.client.get(url).status_code == status.HTTP_200_OK + assert CourseCohortsSettings.objects.filter(course_id=self.course_key).count() == 1 + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class GradebookQueryCountTest(GradebookTestBase): + """ + The v2 reads issue no more queries than v1 on the same fixture, and the list + grows by one query per row, less than v1 grows. + + Each address is requested once before it is measured, so one-off lookups + made by the first request of a test count for neither side. + """ + + #: Measured on this fixture; a change in any of them is a change in the data path. + LIST_QUERIES = 26 + DETAIL_QUERIES = 15 + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def measure(self, url): + """Return the number of queries a second GET of ``url`` issues.""" + assert self.client.get(url).status_code == status.HTTP_200_OK + response, count = count_queries(self.client, url) + assert response.status_code == status.HTTP_200_OK + return count + + def v1_url(self, **query): + url = reverse('grades_api:v1:course_gradebook', kwargs={'course_id': str(self.course_key)}) + return with_query(url, **query) + + def test_list(self): + legacy = self.measure(self.v1_url()) + new = self.measure(gradebook_entry_list_url(self.course_key)) + assert new == self.LIST_QUERIES + assert new <= legacy + + def test_detail(self): + legacy = self.measure(self.v1_url(username='student')) + new = self.measure(gradebook_entry_detail_url(self.course_key, 'student')) + assert new == self.DETAIL_QUERIES + assert new <= legacy + + def test_list_grows_by_one_query_per_row(self): + """ + Each row costs exactly one query: grading a learner checks access to the + course content, and each check reads the authorization engine's policy + version (``PolicyCacheControl``) to decide whether its cached policies + are stale. v1 pays that and reads the learner's program enrollment too. + """ + for index in range(8): + CourseEnrollmentFactory(user=UserFactory(username=f'extra_{index}'), course_id=self.course_key) + five = self.measure(gradebook_entry_list_url(self.course_key, page_size=5)) + ten = self.measure(gradebook_entry_list_url(self.course_key, page_size=10)) + assert ten - five == 5 + legacy_five = self.measure(self.v1_url(page_size=5)) + legacy_ten = self.measure(self.v1_url(page_size=10)) + assert legacy_ten - legacy_five == 10 + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +@override_settings(DEBUG=False) +class GradebookUrlTest(GradebookTestBase): + """The addresses, their names, and the not-found body for addresses no route matches.""" + + COURSE_KEY = 'course-v1:edX+DemoX+Demo_Course' + + def test_reverse_literals(self): + assert reverse('grade_v2:gradebook_entry_list', kwargs={'course_key': self.COURSE_KEY}) == ( + f'/api/grade/v2/courses/{self.COURSE_KEY}/gradebook_entries/' + ) + assert reverse( + 'grade_v2:gradebook_entry_detail', kwargs={'course_key': self.COURSE_KEY, 'username': 'u'}, + ) == f'/api/grade/v2/courses/{self.COURSE_KEY}/gradebook_entries/u/' + + def test_unmatched_addresses_get_a_json_not_found(self): + self.client.force_authenticate(self.global_staff) + for path in ( + '/api/grade/v2/courses/not-a-course-key/gradebook_entries/', + '/api/grade/v2/courses/edX/DemoX/Demo/gradebook_entries/', + '/api/grade/v2/courses/not-a-course-key/gradebook_entries/student/', + f'/api/grade/v2/courses/{self.course_key}/gradebook_entries/student/extra/', + ): + response = self.client.get(path) + assert response['Content-Type'] == 'application/json', path + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug='not-found') + + def test_missing_trailing_slash_redirects(self): + response = self.client.get(f'/api/grade/v2/courses/{self.course_key}/gradebook_entries') + assert response.status_code == status.HTTP_301_MOVED_PERMANENTLY + assert response['Location'].endswith(f'/api/grade/v2/courses/{self.course_key}/gradebook_entries/') diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_infrastructure.py b/lms/djangoapps/grades/rest_api/v2/tests/test_infrastructure.py new file mode 100644 index 000000000000..f84855f32414 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_infrastructure.py @@ -0,0 +1,287 @@ +"""Tests for the pieces the Grades API v2 endpoints share.""" + +from unittest.mock import patch + +import ddt +import pytest +from ccx_keys.locator import CCXLocator +from django.test import SimpleTestCase +from django.test.utils import override_settings +from edx_rest_framework_extensions.errors import classify_error +from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework.views import APIView + +from common.djangoapps.student.roles import CourseInstructorRole, CourseLimitedStaffRole, CourseStaffRole +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.ccx.tests.utils import CcxTestCase +from lms.djangoapps.grades.config.waffle import WRITABLE_GRADEBOOK +from lms.djangoapps.grades.rest_api.v1.gradebook_views import course_author_access_required +from lms.djangoapps.grades.rest_api.v2.errors import GradesFrozen, SubsectionUnavailable, WritableGradebookDisabled +from lms.djangoapps.grades.rest_api.v2.pagination import GradePagination +from lms.djangoapps.grades.rest_api.v2.permissions import CourseExists, HasGradebookAccess, WritableGradebookEnabled +from lms.djangoapps.grades.rest_api.v2.tests.parity import assert_parity +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class _CourseView: + """Stands in for a view that names one course.""" + + def __init__(self, course_key): + self.course_key = course_key + + def get_course_key(self): + return self.course_key + + +class _LegacyGradebookAuthView(DeveloperErrorViewMixin, APIView): + """A view guarded by the v1 gradebook access decorator, to compare the v2 class against.""" + + # The decorator under comparison is the only check. + permission_classes = (AllowAny,) + + @course_author_access_required + def get(self, request, course_key): # pylint: disable=arguments-differ + return Response({'course_key': str(course_key)}) + + +class ErrorTypeTest(SimpleTestCase): + """Each domain error has its own type, and registering it leaves its parent's type alone.""" + + def test_writable_gradebook_disabled(self): + assert classify_error(WritableGradebookDisabled()) == ( + 'grades/writable-gradebook-disabled', 'Writable Gradebook Disabled', + ) + + def test_grades_frozen(self): + assert classify_error(GradesFrozen()) == ('grades/grades-frozen', 'Grades Frozen') + + def test_subsection_unavailable(self): + assert classify_error(SubsectionUnavailable()) == ('grades/subsection-unavailable', 'Subsection Unavailable') + + def test_parent_types_keep_their_catalog_entry(self): + assert classify_error(PermissionDenied()) == ('authz', 'Permission Denied') + assert classify_error(NotFound()) == ('not-found', 'Not Found') + + +class ParityHarnessTest(SimpleTestCase): + """The parity comparison fails on every kind of drift it exists to catch.""" + + LEGACY = {'course_id': 'c', 'percent': 0.5, 'rows': [{'module_id': 'm', 'x': 1}]} + + def test_passes_when_only_declared_changes_occur(self): + new = {'course_key': 'c', 'percent': 0.5, 'rows': [{'usage_key': 'm'}]} + assert_parity( + self.LEGACY, new, + renames={'course_id': 'course_key', 'module_id': 'usage_key'}, + differences=[('rows[*].x', 'dropped')], + ) + + def test_fails_on_an_undeclared_value_change(self): + new = {'course_key': 'c', 'percent': 0.6, 'rows': [{'usage_key': 'm', 'x': 1}]} + with pytest.raises(AssertionError, match='undeclared differences'): + assert_parity(self.LEGACY, new, renames={'course_id': 'course_key', 'module_id': 'usage_key'}) + + def test_fails_when_a_renamed_value_changes(self): + new = {'course_key': 'other', 'percent': 0.5, 'rows': [{'usage_key': 'm', 'x': 1}]} + with pytest.raises(AssertionError, match='undeclared differences'): + assert_parity(self.LEGACY, new, renames={'course_id': 'course_key', 'module_id': 'usage_key'}) + + def test_fails_when_a_declared_difference_does_not_occur(self): + new = {'course_key': 'c', 'percent': 0.5, 'rows': [{'usage_key': 'm', 'x': 1}]} + with pytest.raises(AssertionError, match='declared difference did not occur'): + assert_parity( + self.LEGACY, new, + renames={'course_id': 'course_key', 'module_id': 'usage_key'}, + differences=[('rows[*].x', 'dropped')], + ) + + def test_fails_when_a_declared_rename_does_not_occur(self): + with pytest.raises(AssertionError, match='declared renames did not occur'): + assert_parity(self.LEGACY, self.LEGACY, renames={'user_id': 'username'}) + + +class GradePageSchemaTest(SimpleTestCase): + """The paginator describes all seven members of the body it builds.""" + + def test_schema_lists_every_page_member(self): + schema = GradePagination().get_paginated_response_schema({'type': 'array'}) + assert set(schema['properties']) == { + 'count', 'num_pages', 'current_page', 'start', 'next', 'previous', 'results', + } + assert set(schema['required']) == set(schema['properties']) + assert schema['properties']['results'] == {'type': 'array'} + + def test_defaults(self): + paginator = GradePagination() + assert (paginator.page_size, paginator.max_page_size, paginator.page_size_query_param) == ( + 10, 100, 'page_size', + ) + + +@ddt.ddt +class HasGradebookAccessCcxTest(CcxTestCase): + """ + On CCX courses, the v2 gradebook permission admits exactly the callers the v1 + gradebook decorator admits. + """ + + def setUp(self): + super().setUp() + self.make_coach() + self.ccx = self.make_ccx() + self.ccx_key = CCXLocator.from_course_locator(self.course.id, str(self.ccx.id)) + self.factory = APIRequestFactory() + + def _actor(self, kind): + """Return a user holding the named kind of access to ``self.ccx_key``.""" + if kind == 'coach': + return self.coach + if kind == 'site_staff': + return UserFactory.create(is_staff=True) + user = UserFactory.create() + if kind == 'ccx_staff': + CourseStaffRole(self.ccx_key).add_users(user) + elif kind == 'ccx_instructor': + CourseInstructorRole(self.ccx_key).add_users(user) + elif kind == 'master_staff': + CourseStaffRole(self.course.id).add_users(user) + return user + + def _legacy_allows(self, user, course_key): + """Return whether the v1 decorator lets ``user`` through for ``course_key``.""" + request = self.factory.get('/gradebook/') + force_authenticate(request, user=user) + response = _LegacyGradebookAuthView.as_view()(request, course_id=str(course_key)) + assert response.status_code in (status.HTTP_200_OK, status.HTTP_403_FORBIDDEN) + return response.status_code == status.HTTP_200_OK + + def _v2_allows(self, user, course_key): + """Return whether the v2 permission class lets ``user`` through for ``course_key``.""" + request = self.factory.get('/gradebook/') + request.user = user + return HasGradebookAccess().has_permission(request, _CourseView(course_key)) + + @ddt.data( + ('coach', True), + ('ccx_staff', True), + ('ccx_instructor', True), + ('site_staff', True), + ('master_staff', True), + ('outsider', False), + ) + @ddt.unpack + def test_matches_v1_on_a_ccx_course(self, kind, expected): + user = self._actor(kind) + assert self._v2_allows(user, self.ccx_key) is expected + assert self._legacy_allows(user, self.ccx_key) is expected + + @ddt.data('coach', 'ccx_staff', 'site_staff') + def test_nobody_is_admitted_when_custom_courses_are_off(self, kind): + user = self._actor(kind) + with override_settings(CUSTOM_COURSES_EDX=False): + assert self._v2_allows(user, self.ccx_key) is False + assert self._legacy_allows(user, self.ccx_key) is False + + def test_nobody_is_admitted_when_the_master_course_disables_ccx(self): + master_course = self.store.get_course(self.course.id) + master_course.enable_ccx = False + with patch( + 'lms.djangoapps.grades.rest_api.v1.gradebook_views.get_course_by_id', + return_value=master_course, + ): + assert self._v2_allows(self.coach, self.ccx_key) is False + assert self._legacy_allows(self.coach, self.ccx_key) is False + + def test_ccx_coach_has_no_access_to_the_master_course_itself(self): + assert self._v2_allows(self.coach, self.course.id) is False + assert self._legacy_allows(self.coach, self.course.id) is False + + +@ddt.ddt +class HasGradebookAccessCourseTest(SharedModuleStoreTestCase): + """On an ordinary course, the v2 gradebook permission admits exactly the callers v1 admits.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super().setUp() + self.factory = APIRequestFactory() + + def _actor(self, kind): + """Return a user holding the named role on ``self.course``.""" + if kind == 'global_staff': + return UserFactory.create(is_staff=True) + user = UserFactory.create() + role = { + 'course_staff': CourseStaffRole, + 'course_instructor': CourseInstructorRole, + 'course_limited_staff': CourseLimitedStaffRole, + }.get(kind) + if role: + role(self.course.id).add_users(user) + return user + + @ddt.data( + ('learner', False), + ('course_staff', True), + ('course_instructor', True), + ('course_limited_staff', True), + ('global_staff', True), + ) + @ddt.unpack + def test_matches_v1(self, kind, expected): + user = self._actor(kind) + request = self.factory.get('/gradebook/') + request.user = user + assert HasGradebookAccess().has_permission(request, _CourseView(self.course.id)) is expected + + legacy_request = self.factory.get('/gradebook/') + force_authenticate(legacy_request, user=user) + response = _LegacyGradebookAuthView.as_view()(legacy_request, course_id=str(self.course.id)) + assert response.status_code == (status.HTTP_200_OK if expected else status.HTTP_403_FORBIDDEN) + + +class CourseExistsTest(SharedModuleStoreTestCase): + """A course that exists passes; one that does not is not found.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def test_existing_course_passes(self): + assert CourseExists().has_permission(None, _CourseView(self.course.id)) is True + + def test_missing_course_is_not_found(self): + missing = CourseKey.from_string('course-v1:NoSuch+Course+Run') + with pytest.raises(NotFound): + CourseExists().has_permission(None, _CourseView(missing)) + + +class WritableGradebookEnabledTest(SharedModuleStoreTestCase): + """The permission follows the course's writable-gradebook flag.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def test_flag_on_passes(self): + with override_waffle_flag(WRITABLE_GRADEBOOK, active=True): + assert WritableGradebookEnabled().has_permission(None, _CourseView(self.course.id)) is True + + def test_flag_off_is_refused_with_its_own_type(self): + with override_waffle_flag(WRITABLE_GRADEBOOK, active=False): + with pytest.raises(WritableGradebookDisabled): + WritableGradebookEnabled().has_permission(None, _CourseView(self.course.id)) diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_schema.py b/lms/djangoapps/grades/rest_api/v2/tests/test_schema.py new file mode 100644 index 000000000000..0edfaaf61c44 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_schema.py @@ -0,0 +1,324 @@ +"""Tests for how the Grades API v2 appears in the generated LMS schema.""" + +import re + +from django.test import SimpleTestCase +from drf_spectacular.drainage import GENERATOR_STATS +from drf_spectacular.generators import SchemaGenerator + +API_PREFIX = '/api/grade/v2/' +LEGACY_PREFIX = '/api/grades/v1/' +#: The one generator warning tolerated on v2: the platform's default authentication +#: classes have no schema extension. +TOLERATED_WARNING = re.compile( + r"could not resolve authenticator " +) +ANSI_CODE = re.compile(r'\x1b\[[0-9;]*m') +PAGE_FIELDS = {'count', 'num_pages', 'current_page', 'start', 'next', 'previous', 'results'} +ERROR_REF = '#/components/schemas/ErrorResponse' + +#: The operation id of every v2 operation. +OPERATION_IDS = { + ('/api/grade/v2/course_grades/', 'get'): 'course_grades_list', + ('/api/grade/v2/course_grades/{username},{course_key}/', 'get'): 'course_grades_retrieve', + ('/api/grade/v2/courses/{course_key}/gradebook_entries/', 'get'): 'gradebook_entries_list', + ('/api/grade/v2/courses/{course_key}/gradebook_entries/{username}/', 'get'): 'gradebook_entries_retrieve', + ('/api/grade/v2/courses/{course_key}/subsection_grade_overrides/', 'post'): 'subsection_grade_overrides_create', + ('/api/grade/v2/subsection_grades/{username},{usage_key}/', 'get'): 'subsection_grades_retrieve', + ('/api/grade/v2/subsection_grades/{username},{usage_key}/override_history_records/', 'get'): ( + 'subsection_grade_override_history_records_list' + ), + ('/api/grade/v2/courses/{course_key}/grading_policy/', 'get'): 'course_grading_policy_retrieve', + ('/api/grade/v2/courses/{course_key}/submission_histories/', 'get'): 'submission_histories_list', +} +OPERATION_ID = re.compile(r'^[a-z]+(_[a-z]+)*$') + +#: The v1 operations the schema marks deprecated, one per v1 route. +LEGACY_OPERATIONS = { + ('/api/grades/v1/courses/', 'get'), + ('/api/grades/v1/courses/{course_id}/', 'get'), + ('/api/grades/v1/policy/courses/{course_id}/', 'get'), + ('/api/grades/v1/gradebook/{course_id}/', 'get'), + ('/api/grades/v1/gradebook/{course_id}/bulk-update', 'post'), + ('/api/grades/v1/gradebook/{course_id}/grading-info', 'get'), + ('/api/grades/v1/subsection/{subsection_id}/', 'get'), + ('/api/grades/v1/section_grades_breakdown/', 'get'), + ('/api/grades/v1/submission_history/{course_id}/', 'get'), +} + + +class GradeSchemaTest(SimpleTestCase): + """The LMS schema, generated with the service's own hooks, publishes v2 and deprecates v1.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + GENERATOR_STATS._warn_cache.clear() # pylint: disable=protected-access + GENERATOR_STATS._error_cache.clear() # pylint: disable=protected-access + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + cls.paths = cls.schema['paths'] + cls.messages = [ + ANSI_CODE.sub('', message) + for message in [*GENERATOR_STATS._warn_cache, *GENERATOR_STATS._error_cache] # pylint: disable=protected-access + ] + + def operation(self, path, method='get'): + return self.paths[path][method] + + def resolve(self, schema): + """Follow a component reference.""" + if '$ref' in schema: + return self.schema['components']['schemas'][schema['$ref'].rsplit('/', 1)[-1]] + return schema + + def response_schema(self, path, status_code='200'): + content = self.operation(path)['responses'][status_code]['content']['application/json'] + return self.resolve(content['schema']) + + def v2_operations(self): + return [ + (path, method, operation) + for path, item in self.paths.items() if path.startswith(API_PREFIX) + for method, operation in item.items() + ] + + def test_v2_operations_published(self): + assert {(path, method) for path, method, _ in self.v2_operations()} == { + ('/api/grade/v2/course_grades/', 'get'), + ('/api/grade/v2/course_grades/{username},{course_key}/', 'get'), + ('/api/grade/v2/courses/{course_key}/gradebook_entries/', 'get'), + ('/api/grade/v2/courses/{course_key}/gradebook_entries/{username}/', 'get'), + ('/api/grade/v2/courses/{course_key}/subsection_grade_overrides/', 'post'), + ('/api/grade/v2/subsection_grades/{username},{usage_key}/', 'get'), + ('/api/grade/v2/subsection_grades/{username},{usage_key}/override_history_records/', 'get'), + ('/api/grade/v2/courses/{course_key}/grading_policy/', 'get'), + ('/api/grade/v2/courses/{course_key}/submission_histories/', 'get'), + } + + def test_every_v2_operation_is_tagged(self): + assert {path: operation['tags'] for path, _, operation in self.v2_operations()} == { + '/api/grade/v2/course_grades/': ['openedx-platform-sdk'], + '/api/grade/v2/course_grades/{username},{course_key}/': ['openedx-platform-sdk'], + '/api/grade/v2/courses/{course_key}/gradebook_entries/': ['grade'], + '/api/grade/v2/courses/{course_key}/gradebook_entries/{username}/': ['grade'], + '/api/grade/v2/courses/{course_key}/subsection_grade_overrides/': ['grade'], + '/api/grade/v2/subsection_grades/{username},{usage_key}/': ['grade'], + '/api/grade/v2/subsection_grades/{username},{usage_key}/override_history_records/': ['grade'], + '/api/grade/v2/courses/{course_key}/grading_policy/': ['openedx-platform-sdk'], + '/api/grade/v2/courses/{course_key}/submission_histories/': ['grade'], + } + + def test_sdk_operations_have_explicit_operation_ids(self): + assert { + operation['operationId'] for _, _, operation in self.v2_operations() + if operation['tags'] == ['openedx-platform-sdk'] + } == {'course_grades_list', 'course_grades_retrieve', 'course_grading_policy_retrieve'} + + def test_operation_ids_are_unique(self): + ids = [operation['operationId'] for _, _, operation in self.v2_operations()] + assert len(ids) == len(set(ids)) + + def test_every_operation_id_is_the_declared_one(self): + assert {(path, method): operation['operationId'] for path, method, operation in self.v2_operations()} == ( + OPERATION_IDS + ) + assert [name for name in OPERATION_IDS.values() if not OPERATION_ID.match(name)] == [] + + def test_v2_generates_no_warning_but_the_default_authentication_one(self): + v2_messages = [message for message in self.messages if '/grades/rest_api/v2/' in message] + assert len(v2_messages) == 14, v2_messages + assert [message for message in v2_messages if not TOLERATED_WARNING.search(message)] == [] + + def test_unmatched_routes_are_not_published(self): + assert not [path for path in self.paths if 'unmatched' in path] + + def test_every_legacy_operation_is_deprecated(self): + legacy = { + (path, method) for path, item in self.paths.items() if path.startswith(LEGACY_PREFIX) + for method, operation in item.items() if operation.get('deprecated') is True + } + assert legacy == LEGACY_OPERATIONS + + def test_no_v2_operation_is_deprecated(self): + assert [path for path, _, operation in self.v2_operations() if 'deprecated' in operation] == [] + + def test_every_documented_error_references_the_error_component(self): + for path, method, operation in self.v2_operations(): + for status_code, response in operation['responses'].items(): + if status_code.startswith(('4', '5')): + assert response['content']['application/json']['schema'] == {'$ref': ERROR_REF}, ( + path, method, status_code, + ) + + def test_course_grade_operations(self): + list_operation = self.operation('/api/grade/v2/course_grades/') + detail_operation = self.operation('/api/grade/v2/course_grades/{username},{course_key}/') + assert (list_operation['operationId'], list_operation['tags']) == ( + 'course_grades_list', ['openedx-platform-sdk'], + ) + assert (detail_operation['operationId'], detail_operation['tags']) == ( + 'course_grades_retrieve', ['openedx-platform-sdk'], + ) + assert set(list_operation['responses']) == {'200', '400', '401', '403'} + assert set(detail_operation['responses']) == {'200', '400', '401', '403', '404'} + + def test_course_grade_list_is_the_page_envelope(self): + body = self.response_schema('/api/grade/v2/course_grades/') + assert set(body['properties']) == PAGE_FIELDS + row = self.resolve(body['properties']['results']['items']) + assert set(row['properties']) == { + 'username', 'course_key', 'email', 'passed', 'percent', 'letter_grade', 'section_breakdown', + } + assert row['properties']['letter_grade']['nullable'] is True + assert 'section_breakdown' not in row['required'] + + def test_course_grade_list_parameters(self): + parameters = { + parameter['name']: parameter + for parameter in self.operation('/api/grade/v2/course_grades/')['parameters'] + } + assert set(parameters) == {'course_key', 'username', 'ordering', 'view', 'page', 'page_size'} + assert parameters['view']['schema']['enum'] == ['full'] + + def test_course_grade_detail_path_parameters(self): + parameters = { + parameter['name']: parameter['in'] + for parameter in self.operation('/api/grade/v2/course_grades/{username},{course_key}/')['parameters'] + } + assert parameters == {'username': 'path', 'course_key': 'path', 'view': 'query'} + + def test_gradebook_list_is_the_page_envelope(self): + body = self.response_schema('/api/grade/v2/courses/{course_key}/gradebook_entries/') + assert set(body['properties']) == PAGE_FIELDS + row = self.resolve(body['properties']['results']['items']) + assert set(row['properties']) == { + 'username', 'full_name', 'email', 'external_user_key', 'percent', 'section_breakdown', + } + # The minimal view keeps only the username and percent of each row. + assert set(row['required']) == {'username', 'percent'} + section = self.resolve(row['properties']['section_breakdown']['items']) + assert 'usage_key' in section['properties'] + assert 'module_id' not in section['properties'] + assert section['properties']['label']['nullable'] is True + + def test_gradebook_list_parameters(self): + operation = self.operation('/api/grade/v2/courses/{course_key}/gradebook_entries/') + parameters = {parameter['name']: parameter['in'] for parameter in operation['parameters']} + assert parameters == { + 'course_key': 'path', + 'user_contains': 'query', + 'username_contains': 'query', + 'cohort_id': 'query', + 'enrollment_mode': 'query', + 'excluded_course_roles': 'query', + 'assignment_usage_key': 'query', + 'assignment_grade_min': 'query', + 'assignment_grade_max': 'query', + 'course_grade_min': 'query', + 'course_grade_max': 'query', + 'ordering': 'query', + 'view': 'query', + 'page': 'query', + 'page_size': 'query', + } + assert set(operation['responses']) == {'200', '400', '401', '403', '404'} + + def test_gradebook_filter_parameter_types(self): + operation = self.operation('/api/grade/v2/courses/{course_key}/gradebook_entries/') + parameters = {parameter['name']: parameter for parameter in operation['parameters']} + roles = parameters['excluded_course_roles'] + assert (roles['schema'], roles['style'], roles['explode']) == ( + {'type': 'array', 'items': {'type': 'string'}}, 'form', True, + ) + assert roles['description'].startswith('Leave out learners who hold this role in the course.') + assert parameters['cohort_id']['schema'] == {'type': 'integer'} + assert parameters['assignment_usage_key']['schema'] == {'type': 'string'} + assert parameters['assignment_usage_key']['description'] == ( + 'Key of the subsection that assignment_grade_min and assignment_grade_max apply to.' + ) + + def test_gradebook_detail_parameters(self): + operation = self.operation('/api/grade/v2/courses/{course_key}/gradebook_entries/{username}/') + parameters = {parameter['name']: parameter['in'] for parameter in operation['parameters']} + assert parameters == {'course_key': 'path', 'username': 'path', 'view': 'query'} + + def test_override_request_body_differs_from_its_response(self): + operation = self.operation('/api/grade/v2/courses/{course_key}/subsection_grade_overrides/', 'post') + request = self.resolve(operation['requestBody']['content']['application/json']['schema']) + response = self.resolve(operation['responses']['200']['content']['application/json']['schema']) + assert operation['requestBody']['required'] is True + assert list(operation['requestBody']['content']) == ['application/json'] + assert list(request['properties']) == ['overrides'] + item = self.resolve(request['properties']['overrides']['items']) + assert set(item['required']) == {'username', 'usage_key'} + assert item['properties']['comment']['maxLength'] == 300 + assert response != request + assert set(self.resolve(response['properties']['overrides']['items'])['properties']) == { + 'username', 'usage_key', 'earned_all_override', 'possible_all_override', + 'earned_graded_override', 'possible_graded_override', 'comment', + } + assert set(operation['responses']) == {'200', '400', '401', '403', '404', '500'} + + def test_override_examples(self): + operation = self.operation('/api/grade/v2/courses/{course_key}/subsection_grade_overrides/', 'post') + request_example = operation['requestBody']['content']['application/json']['examples']['TwoOverrides'] + assert [item['username'] for item in request_example['value']['overrides']] == ['learner_one', 'learner_two'] + error_example = operation['responses']['400']['content']['application/json']['examples']['InvalidItems'] + assert list(error_example['value']['errors']) == ['overrides[0].username', 'overrides[1].usage_key'] + assert error_example['value']['type'] == 'https://docs.openedx.org/errors/validation' + + def test_subsection_grade_operation(self): + body = self.response_schema('/api/grade/v2/subsection_grades/{username},{usage_key}/') + assert set(body['properties']) == {'username', 'usage_key', 'course_key', 'original_grade', 'override'} + assert body['properties']['override']['nullable'] is True + parameters = { + parameter['name']: parameter['in'] + for parameter in self.operation('/api/grade/v2/subsection_grades/{username},{usage_key}/')['parameters'] + } + assert parameters == {'username': 'path', 'usage_key': 'path'} + + def test_override_history_is_the_page_envelope(self): + path = '/api/grade/v2/subsection_grades/{username},{usage_key}/override_history_records/' + body = self.response_schema(path) + assert set(body['properties']) == PAGE_FIELDS + record = self.resolve(body['properties']['results']['items']) + assert 'history_user_id' not in record['properties'] + assert record['properties']['history_user']['nullable'] is True + parameters = {parameter['name'] for parameter in self.operation(path)['parameters']} + assert parameters == {'username', 'usage_key', 'ordering', 'page', 'page_size'} + + def test_grading_policy_operation(self): + path = '/api/grade/v2/courses/{course_key}/grading_policy/' + body = self.response_schema(path) + assert set(body['properties']) == { + 'grade_cutoffs', 'assignment_types', 'subsections', 'grades_frozen', 'bulk_management_enabled', + } + assert set(body['required']) == {'grade_cutoffs', 'assignment_types'} + subsection = self.resolve(body['properties']['subsections']['items']) + assert subsection['properties']['display_name']['nullable'] is True + parameters = {parameter['name']: parameter for parameter in self.operation(path)['parameters']} + assert set(parameters) == {'course_key', 'view', 'graded_only'} + assert parameters['view']['schema']['enum'] == ['minimal'] + + def test_submission_histories_are_the_page_envelope(self): + path = '/api/grade/v2/courses/{course_key}/submission_histories/' + body = self.response_schema(path) + assert set(body['properties']) == PAGE_FIELDS + parameters = {parameter['name'] for parameter in self.operation(path)['parameters']} + assert parameters == {'course_key', 'username', 'ordering', 'view', 'page', 'page_size'} + assert '429' in self.operation(path)['responses'] + + def test_serializer_components_are_namespaced(self): + assert { + 'grade_v2.CourseGrade', + 'grade_v2.CourseGradeSection', + 'grade_v2.GradebookEntry', + 'grade_v2.GradebookSection', + 'grade_v2.SubsectionGradeOverrideRequest', + 'grade_v2.SubsectionGradeOverrideList', + 'grade_v2.SubsectionGrade', + 'grade_v2.SubsectionGradeOverrideHistoryRecord', + 'grade_v2.CourseGradingPolicy', + 'grade_v2.SubmissionHistory', + } <= set(self.schema['components']['schemas']) diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_submission_histories.py b/lms/djangoapps/grades/rest_api/v2/tests/test_submission_histories.py new file mode 100644 index 000000000000..e55a3a8db61c --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_submission_histories.py @@ -0,0 +1,406 @@ +"""Tests for the submission history endpoint of the Grades API v2.""" + +import json +from contextlib import ExitStack +from unittest.mock import patch + +import ddt +from django.core.cache import cache +from django.db import connections +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import reverse +from edx_django_utils.cache import RequestCache +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from common.djangoapps.util.models import RateLimitConfiguration +from lms.djangoapps.courseware.tests.factories import StudentModuleFactory +from lms.djangoapps.grades.rest_api.v1.tests.mixins import GradeViewTestMixin +from lms.djangoapps.grades.rest_api.v2.tests.mixins import ( + GRADE_READ_SCOPE, + PAGE_FIELDS, + CohortedCourseMixin, + ReadOnlyRequestMixin, + capture_cohort_assignment, + submission_history_list_url, + token_header, + usernames_in, + warm_read_caches, + with_query, +) +from lms.djangoapps.grades.rest_api.v2.tests.parity import assert_parity +from openedx.core.djangoapps.enrollments.views import EnrollmentUserThrottle + +MISSING_COURSE_KEY = 'course-v1:NoSuch+Course+Run' +FIXTURE_LEARNERS = ['student', 'other_student', 'program_student', 'program_masters_student'] +DATABASES = {'default', 'student_module_history'} + + +class SubmissionHistoryTestBase(GradeViewTestMixin, APITestCase): + """The shared course, with two recorded submissions by one learner to its first problem.""" + + databases = DATABASES + + def setUp(self): + super().setUp() + cache.clear() + warm_read_caches(self.course_key) + vertical = self.store.get_course(self.course_key).get_children()[0].get_children()[0].get_children()[0] + self.problem = vertical.get_children()[0] + self.submit(self.student, self.problem, grade=0, max_grade=1) + self.submit(self.student, self.problem, grade=1, max_grade=1) + + def submit(self, user, problem, grade, max_grade): + """Record one submission by ``user`` to ``problem``, keeping the history of earlier ones.""" + module, _ = StudentModuleFactory._meta.model.objects.get_or_create( # pylint: disable=protected-access + student=user, course_id=self.course_key, module_state_key=problem.location, + defaults={'module_type': 'problem'}, + ) + # History records made within one request are merged, and each call stands for one request. + RequestCache.clear_all_namespaces() + module.state = json.dumps({'attempts': grade + 1}) + module.grade = grade + module.max_grade = max_grade + module.save() + return module + + def login(self, user): + """Sign ``user`` in with a browser session.""" + self.client.login(username=user.username, password=self.password) + + +@ddt.ddt +class SubmissionHistoryAccessTest(SubmissionHistoryTestBase): + """Only global staff may read submission histories.""" + + def test_anonymous_caller_is_refused(self): + assert_error_envelope( + self.client.get(submission_history_list_url(self.course_key)), + expected_status=401, expected_type_slug='authn', + ) + + @ddt.data('learner', 'course_staff', 'course_instructor') + def test_everyone_but_global_staff_is_refused(self, kind): + user = {'learner': lambda: self.student}.get(kind, lambda: UserFactory(password=self.password))() + if kind != 'learner': + role = {'course_staff': CourseStaffRole, 'course_instructor': CourseInstructorRole}[kind] + role(self.course_key).add_users(user) + self.login(user) + assert_error_envelope( + self.client.get(submission_history_list_url(self.course_key)), + expected_status=403, expected_type_slug='authz', + ) + + def test_learner_cannot_read_even_their_own_history(self): + self.login(self.student) + response = self.client.get(submission_history_list_url(self.course_key, username='student')) + assert_error_envelope(response, expected_status=403, expected_type_slug='authz') + + def test_global_staff_are_admitted(self): + self.login(self.global_staff) + response = self.client.get(submission_history_list_url(self.course_key)) + assert usernames_in(response) == FIXTURE_LEARNERS + + def test_caller_without_access_cannot_learn_whether_a_course_exists(self): + self.login(self.student) + assert_error_envelope( + self.client.get(submission_history_list_url(MISSING_COURSE_KEY)), + expected_status=403, expected_type_slug='authz', + ) + + def test_missing_course_is_not_found(self): + self.login(self.global_staff) + assert_error_envelope( + self.client.get(submission_history_list_url(MISSING_COURSE_KEY)), + expected_status=404, expected_type_slug='not-found', + ) + + @ddt.data((), (GRADE_READ_SCOPE,)) + def test_restricted_token_is_refused_even_for_global_staff(self, scopes): + headers = token_header( + self.global_staff, scopes=scopes, is_restricted=True, filters=[f'content_org:{self.course_key.org}'], + ) + response = self.client.get(submission_history_list_url(self.course_key), **headers) + assert_error_envelope(response, expected_status=403, expected_type_slug='authz') + + def test_unrestricted_token_for_global_staff_is_admitted(self): + response = self.client.get(submission_history_list_url(self.course_key), **token_header(self.global_staff)) + assert usernames_in(response) == FIXTURE_LEARNERS + + def test_restricted_token_is_refused_by_v1_too(self): + headers = token_header( + self.global_staff, is_restricted=True, filters=[f'content_org:{self.course_key.org}'], + ) + legacy_url = reverse('grades_api:v1:submission_history', kwargs={'course_id': str(self.course_key)}) + legacy = self.client.get(legacy_url, **headers) + new = self.client.get(submission_history_list_url(self.course_key), **headers) + assert (legacy.status_code, new.status_code) == (status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN) + + +@override_settings(CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}) +class SubmissionHistoryThrottleTest(SubmissionHistoryTestBase): + """Requests are rate limited unless rate limiting is switched off for the site.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + self.url = submission_history_list_url(self.course_key) + + def statuses(self, count): + with patch.dict(EnrollmentUserThrottle.THROTTLE_RATES, {'staff': '2/minute'}): + return [self.client.get(self.url).status_code for _ in range(count)] + + def test_limit_applies_when_rate_limiting_is_on(self): + RateLimitConfiguration.objects.create(enabled=True) + assert self.statuses(2) == [200, 200] + with patch.dict(EnrollmentUserThrottle.THROTTLE_RATES, {'staff': '2/minute'}): + response = self.client.get(self.url) + assert_error_envelope(response, expected_status=429, expected_type_slug='rate-limited') + + def test_limit_is_lifted_when_rate_limiting_is_off(self): + RateLimitConfiguration.objects.create(enabled=False) + assert self.statuses(3) == [200, 200, 200] + + +class SubmissionHistoryContentTest(SubmissionHistoryTestBase): + """The rows and what they hold.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def body(self, **query): + response = self.client.get(submission_history_list_url(self.course_key, **query)) + assert response.status_code == status.HTTP_200_OK, response.content + return response.json() + + def test_rows(self): + body = self.body() + assert set(body) == PAGE_FIELDS + assert body['results'][0] == { + 'username': 'student', + 'problems': [{ + 'usage_key': str(self.problem.location), + 'display_name': self.problem.display_name, + 'submissions': [ + {'state': {'attempts': 2}, 'grade': 1.0, 'max_grade': 1.0}, + {'state': {'attempts': 1}, 'grade': 0.0, 'max_grade': 1.0}, + {'state': None, 'grade': None, 'max_grade': None}, + ], + }], + } + assert [row['problems'] for row in body['results'][1:]] == [[], [], []] + + def test_each_learner_gets_only_their_own_submissions(self): + second_problem = self.problem.get_parent().get_parent().get_parent().get_children()[1].get_children()[0] + self.submit(self.other_student, second_problem.get_children()[0], grade=1, max_grade=2) + rows = {row['username']: row['problems'] for row in self.body()['results']} + assert [problem['usage_key'] for problem in rows['student']] == [str(self.problem.location)] + other = [(problem['usage_key'], problem['submissions'][0]['max_grade']) for problem in rows['other_student']] + assert other == [(str(second_problem.get_children()[0].location), 2.0)] + + def test_full_view_adds_each_problem_definition(self): + problem = self.body(view='full')['results'][0]['problems'][0] + assert problem['data'] == self.problem.data + + def test_unknown_view_is_refused(self): + response = self.client.get(submission_history_list_url(self.course_key, view='minimal')) + body = assert_error_envelope(response, expected_status=400, expected_type_slug='validation') + assert list(body['errors']) == ['view'] + + def test_username_filter(self): + assert usernames_in(self.client.get(submission_history_list_url( + self.course_key, username='student,program_student', + ))) == ['student', 'program_student'] + + def test_more_than_a_hundred_usernames_are_refused(self): + response = self.client.get(submission_history_list_url( + self.course_key, username=','.join(f'user{index}' for index in range(101)), + )) + body = assert_error_envelope(response, expected_status=400, expected_type_slug='validation') + assert list(body['errors']) == ['username'] + + def test_ordering(self): + assert usernames_in(self.client.get(submission_history_list_url(self.course_key, ordering='username'))) == [ + 'other_student', 'program_masters_student', 'program_student', 'student', + ] + response = self.client.get(submission_history_list_url(self.course_key, ordering='bogus')) + assert_error_envelope(response, expected_status=400, expected_type_slug='validation') + + def test_inactive_enrollments_are_left_out(self): + CourseEnrollment.objects.filter(user=self.other_student, course_id=self.course_key).update(is_active=False) + assert 'other_student' not in usernames_in(self.client.get(submission_history_list_url(self.course_key))) + + def test_submissions_in_another_course_are_left_out(self): + CourseEnrollmentFactory(user=self.student, course_id=self.empty_course.id) + rows = self.body()['results'] + assert [problem['usage_key'] for problem in rows[0]['problems']] == [str(self.problem.location)] + other = self.client.get(submission_history_list_url(self.empty_course.id)).json()['results'] + assert other == [{'username': 'student', 'problems': []}] + + def test_paging(self): + body = self.body(page_size=3, page=2) + assert (body['count'], body['num_pages'], body['current_page'], body['start']) == (4, 2, 2, 3) + assert [row['username'] for row in body['results']] == ['program_masters_student'] + + +class SubmissionHistoryParityTest(SubmissionHistoryTestBase): + """The v2 body equals the v1 body it replaces, apart from the declared differences.""" + + RENAMES = { + 'user': 'username', + 'location': 'usage_key', + 'name': 'display_name', + 'submission_history': 'submissions', + } + DIFFERENCES = [ + ('count', 'page-number envelope replaces the cursor envelope'), + ('num_pages', 'page-number envelope replaces the cursor envelope'), + ('current_page', 'page-number envelope replaces the cursor envelope'), + ('start', 'page-number envelope replaces the cursor envelope'), + ('results[*].course_id', 'the course is the address'), + ('results[*].course_name', 'the course is the address'), + ] + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def v1(self, **query): + url = reverse('grades_api:v1:submission_history', kwargs={'course_id': str(self.course_key)}) + response = self.client.get(with_query(url, **query)) + assert response.status_code == status.HTTP_200_OK + return response.json() + + def test_full_view(self): + new = self.client.get(submission_history_list_url(self.course_key, view='full')).json() + assert_parity(self.v1(), new, renames=self.RENAMES, differences=self.DIFFERENCES) + + def test_default_view_leaves_the_problem_definitions_out(self): + new = self.client.get(submission_history_list_url(self.course_key)).json() + assert_parity( + self.v1(), new, renames=self.RENAMES, + differences=[*self.DIFFERENCES, ('results[*].problems[*].data', 'returned only for view=full')], + ) + + def test_one_learner(self): + new = self.client.get(submission_history_list_url(self.course_key, username='student', view='full')).json() + assert_parity(self.v1(username='student'), new, renames=self.RENAMES, differences=self.DIFFERENCES) + + def test_missing_course_is_a_200_in_v1_and_a_404_in_v2(self): + url = reverse('grades_api:v1:submission_history', kwargs={'course_id': MISSING_COURSE_KEY}) + assert self.client.get(url).json()['results'] == [] + assert self.client.get(submission_history_list_url(MISSING_COURSE_KEY)).status_code == 404 + + +def _capture_every_database(): + """Return an ExitStack capturing the queries of both databases, and the two captures.""" + stack = ExitStack() + captures = [stack.enter_context(CaptureQueriesContext(connections[alias])) for alias in sorted(DATABASES)] + return stack, captures + + +class SubmissionHistoryReadOnlyTest(ReadOnlyRequestMixin, SubmissionHistoryTestBase): + """Reading submission histories changes nothing, in either database.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def test_list_and_head(self): + stack, (default, history) = _capture_every_database() + with stack, self.assert_nothing_is_written(): + for query in ({}, {'view': 'full'}): + assert self.client.get(submission_history_list_url(self.course_key, **query)).status_code == 200 + assert self.client.head(submission_history_list_url(self.course_key)).status_code == 200 + writes = [ + query['sql'] for query in history.captured_queries + if query['sql'].lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE', 'REPLACE')) + ] + assert (writes, len(default.captured_queries) > 0) == ([], True) + + +class SubmissionHistoryCohortAssignmentTest(CohortedCourseMixin, SubmissionHistoryTestBase): + """ + On a course whose content groups follow cohorts, reading submission + histories assigns no learner to a cohort, including a learner who is in none. + """ + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + self.submit(self.uncohorted, self.problem, grade=1, max_grade=1) + + def test_each_view(self): + for query in ({}, {'view': 'full'}): + with capture_cohort_assignment() as record: + response = self.client.get(submission_history_list_url(self.course_key, **query)) + assert response.status_code == status.HTTP_200_OK + assert 'uncohorted' in usernames_in(response) + assert (record.tables, record.memberships, record.tracking) == ([], [], []) + assert self.cohort_of(self.uncohorted) is None + + +class SubmissionHistoryQueryCountTest(SubmissionHistoryTestBase): + """ + v2 issues no more queries than v1 on the same page, and the count does not grow with the page. + + Queries are counted in both databases, since submission histories live in + their own. Each address is requested once first. + """ + + #: Measured on this fixture; a change is a change in the data path. v1 + #: reads each learner's submissions to each problem one at a time. + LIST_QUERIES = 14 + V1_LIST_QUERIES = 223 + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def measure(self, url): + """Return the number of queries, in both databases, the second GET of ``url`` issues.""" + assert self.client.get(url).status_code == status.HTTP_200_OK + stack, captures = _capture_every_database() + with stack: + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + return sum(len(capture.captured_queries) for capture in captures) + + def test_list(self): + legacy = self.measure(reverse('grades_api:v1:submission_history', kwargs={'course_id': str(self.course_key)})) + new = self.measure(submission_history_list_url(self.course_key)) + assert (new, legacy) == (self.LIST_QUERIES, self.V1_LIST_QUERIES) + + def test_count_does_not_grow_with_the_page(self): + self.submit(self.other_student, self.problem, grade=1, max_grade=1) + two = self.measure(submission_history_list_url(self.course_key, page_size=2)) + four = self.measure(submission_history_list_url(self.course_key, page_size=4)) + assert two == four + + +@override_settings(DEBUG=False) +class SubmissionHistoryUrlTest(SubmissionHistoryTestBase): + """The address, its name, and the not-found body for addresses no route matches.""" + + COURSE_KEY = 'course-v1:edX+DemoX+Demo_Course' + + def test_reverse_literal(self): + assert reverse('grade_v2:submission_history_list', kwargs={'course_key': self.COURSE_KEY}) == ( + f'/api/grade/v2/courses/{self.COURSE_KEY}/submission_histories/' + ) + + def test_unmatched_addresses_get_a_json_not_found(self): + self.client.force_authenticate(self.global_staff) + for path in ( + '/api/grade/v2/courses/not-a-course-key/submission_histories/', + '/api/grade/v2/courses/edX/DemoX/Demo/submission_histories/', + f'/api/grade/v2/courses/{self.course_key}/submission_histories/student/', + ): + response = self.client.get(path) + assert response['Content-Type'] == 'application/json', path + assert_error_envelope(response, expected_status=404, expected_type_slug='not-found') diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grade_overrides.py b/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grade_overrides.py new file mode 100644 index 000000000000..b405bf93d4d9 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grade_overrides.py @@ -0,0 +1,627 @@ +"""Tests for the subsection grade override endpoint of the Grades API v2.""" + +import json +from datetime import timedelta +from unittest.mock import patch + +import ddt +from ccx_keys.locator import CCXLocator +from django.test.utils import override_settings +from django.urls import reverse +from django.utils import timezone +from edx_rest_framework_extensions.testing import assert_error_envelope +from edx_toggles.toggles.testutils import override_waffle_flag +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole, +) +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.ccx.tests.utils import CcxTestCase +from lms.djangoapps.grades.config.waffle import ENFORCE_FREEZE_GRADE_AFTER_COURSE_END, WRITABLE_GRADEBOOK +from lms.djangoapps.grades.constants import GradeOverrideFeatureEnum +from lms.djangoapps.grades.models import ( + PersistentCourseGrade, + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, +) +from lms.djangoapps.grades.rest_api.v1.tests.mixins import GradeViewTestMixin +from lms.djangoapps.grades.rest_api.v2.serializers import MAX_OVERRIDES +from lms.djangoapps.grades.rest_api.v2.tests.mixins import ( + GRADE_READ_SCOPE, + count_post_queries, + subsection_grade_override_list_url, + token_header, + warm_cohort_settings, + warm_read_caches, +) +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +MISSING_COURSE_KEY = 'course-v1:NoSuch+Course+Run' +OVERRIDE_VALUES = { + 'earned_all_override': 3.0, + 'possible_all_override': 4.0, + 'earned_graded_override': 2.0, + 'possible_graded_override': 4.0, +} + + +class OverrideTestBase(GradeViewTestMixin, APITestCase): + """Fixtures shared by the override endpoint tests.""" + + def setUp(self): + super().setUp() + warm_read_caches(self.course_key) + warm_cohort_settings(self.course_key) + subsections = self.store.get_course(self.course_key).get_children()[0].get_children() + self.homework, self.second_homework = subsections[0], subsections[1] + + def login(self, user): + """Sign ``user`` in with a browser session.""" + self.client.login(username=user.username, password=self.password) + + def user_with_role(self, role): + """Return a new user holding ``role`` on the test course.""" + user = UserFactory(password=self.password) + role(self.course_key).add_users(user) + return user + + def item(self, username='student', usage_key=None, **values): + """Return one request item overriding ``username``'s grade on a subsection.""" + return { + 'username': username, + 'usage_key': str(usage_key or self.homework.location), + **OVERRIDE_VALUES, + **values, + } + + def post(self, *items, course_key=None, client=None): + """Send ``items`` as one batch and return the response.""" + return (client or self.client).post( + subsection_grade_override_list_url(course_key or self.course_key), + data=json.dumps({'overrides': list(items)}), + content_type='application/json', + ) + + def stored_overrides(self): + """Return ``{(username, usage key): override values}`` for every override in the database.""" + return { + (override.grade.user_id, str(override.grade.usage_key)): { + name: getattr(override, name) for name in (*OVERRIDE_VALUES, 'override_reason', 'system') + } + for override in PersistentSubsectionGradeOverride.objects.select_related('grade') + } + + def set_course_end(self, end): + """Set the end date the course overview records.""" + CourseOverview.objects.filter(id=self.course_key).update(end=end) + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +@ddt.ddt +class OverrideAccessTest(OverrideTestBase): + """Who may record overrides while the writable gradebook is on and grades are not frozen.""" + + def test_anonymous_caller_is_refused(self): + assert_error_envelope(self.post(self.item()), expected_status=401, expected_type_slug='authn') + assert self.stored_overrides() == {} + + @ddt.data('student', 'other_student') + def test_learners_are_refused(self, username): + self.login(getattr(self, username)) + assert_error_envelope(self.post(self.item()), expected_status=403, expected_type_slug='authz') + assert self.stored_overrides() == {} + + def test_staff_of_another_course_are_refused(self): + other_staff = UserFactory(password=self.password) + CourseStaffRole(self.empty_course.id).add_users(other_staff) + self.login(other_staff) + assert_error_envelope(self.post(self.item()), expected_status=403, expected_type_slug='authz') + + @ddt.data(CourseStaffRole, CourseInstructorRole, CourseLimitedStaffRole) + def test_course_team_is_admitted(self, role): + self.login(self.user_with_role(role)) + response = self.post(self.item()) + assert response.status_code == status.HTTP_200_OK, response.content + assert list(self.stored_overrides()) == [(self.student.id, str(self.homework.location))] + + def test_global_staff_are_admitted(self): + self.login(self.global_staff) + assert self.post(self.item()).status_code == status.HTTP_200_OK + + def test_caller_without_access_cannot_learn_whether_a_course_exists(self): + self.login(self.student) + assert_error_envelope( + self.post(self.item(), course_key=MISSING_COURSE_KEY), expected_status=403, expected_type_slug='authz', + ) + + def test_missing_course_is_not_found_for_a_caller_with_access(self): + self.login(self.global_staff) + assert_error_envelope( + self.post(self.item(), course_key=MISSING_COURSE_KEY), expected_status=404, expected_type_slug='not-found', + ) + + def test_session_caller_without_a_csrf_token_is_refused(self): + client = APIClient(enforce_csrf_checks=True) + client.login(username=self.global_staff.username, password=self.password) + assert_error_envelope(self.post(self.item(), client=client), expected_status=403, expected_type_slug='authz') + assert self.stored_overrides() == {} + + def test_reading_the_collection_is_not_allowed(self): + self.login(self.global_staff) + response = self.client.get(subsection_grade_override_list_url(self.course_key)) + # edx-drf-extensions does not catalog MethodNotAllowed yet, so its type is + # internal. Expect its own slug once the pinned release catalogs it. + assert_error_envelope( + response, expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, expected_type_slug='internal', + ) + + @ddt.data((), (GRADE_READ_SCOPE,)) + def test_restricted_token_is_refused_even_for_global_staff(self, scopes): + headers = token_header( + self.global_staff, scopes=scopes, is_restricted=True, filters=[f'content_org:{self.course_key.org}'], + ) + response = self.client.post( + subsection_grade_override_list_url(self.course_key), + data=json.dumps({'overrides': [self.item()]}), content_type='application/json', **headers, + ) + assert_error_envelope(response, expected_status=403, expected_type_slug='authz') + assert self.stored_overrides() == {} + + def test_unrestricted_token_for_global_staff_is_admitted(self): + response = self.client.post( + subsection_grade_override_list_url(self.course_key), + data=json.dumps({'overrides': [self.item()]}), content_type='application/json', + **token_header(self.global_staff), + ) + assert response.status_code == status.HTTP_200_OK, response.content + assert list(self.stored_overrides()) == [(self.student.id, str(self.homework.location))] + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=False) +class OverrideGradebookDisabledTest(OverrideTestBase): + """A course with the writable gradebook switched off.""" + + def test_caller_with_access_is_refused_with_the_feature_type(self): + self.login(self.global_staff) + assert_error_envelope( + self.post(self.item()), expected_status=403, expected_type_slug='grades/writable-gradebook-disabled', + ) + assert self.stored_overrides() == {} + + def test_caller_without_access_cannot_learn_the_setting(self): + self.login(self.student) + assert_error_envelope(self.post(self.item()), expected_status=403, expected_type_slug='authz') + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +@ddt.ddt +class OverrideFrozenGradesTest(OverrideTestBase): + """Grades freeze a fixed number of days after the course ends, when freezing is switched on.""" + + def setUp(self): + super().setUp() + self.login(self.global_staff) + + @ddt.data( + (True, -60, True), + (True, 60, False), + (True, None, False), + (False, -60, False), + ) + @ddt.unpack + def test_freeze(self, enforced, end_in_days, frozen): + self.set_course_end(None if end_in_days is None else timezone.now() + timedelta(days=end_in_days)) + with override_waffle_flag(ENFORCE_FREEZE_GRADE_AFTER_COURSE_END, active=enforced): + response = self.post(self.item()) + if frozen: + assert_error_envelope(response, expected_status=403, expected_type_slug='grades/grades-frozen') + assert self.stored_overrides() == {} + else: + assert response.status_code == status.HTTP_200_OK, response.content + assert len(self.stored_overrides()) == 1 + + def test_caller_without_access_cannot_learn_that_grades_are_frozen(self): + self.set_course_end(timezone.now() - timedelta(days=60)) + self.login(self.student) + with override_waffle_flag(ENFORCE_FREEZE_GRADE_AFTER_COURSE_END, active=True): + assert_error_envelope(self.post(self.item()), expected_status=403, expected_type_slug='authz') + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class OverrideCcxTest(CcxTestCase, APITestCase): + """Recording overrides in a CCX course.""" + + def setUp(self): + super().setUp() + self.make_coach() + self.ccx = self.make_ccx() + self.ccx_key = CCXLocator.from_course_locator(self.course.id, str(self.ccx.id)) + self.learner = UserFactory() + CourseEnrollmentFactory(user=self.learner, course_id=self.ccx_key) + block = self.sequentials[0].location + self.usage_key = self.ccx_key.make_usage_key(block.block_type, block.block_id) + + def post(self): + return self.client.post( + subsection_grade_override_list_url(self.ccx_key), + data=json.dumps({'overrides': [ + {'username': self.learner.username, 'usage_key': str(self.usage_key), **OVERRIDE_VALUES}, + ]}), + content_type='application/json', + ) + + def test_coach_records_an_override(self): + self.client.force_authenticate(self.coach) + response = self.post() + assert response.status_code == status.HTTP_200_OK, response.content + assert response.json()['overrides'][0]['usage_key'] == str(self.usage_key) + grade = PersistentSubsectionGrade.read_grade(self.learner.id, self.usage_key) + assert grade.override.earned_graded_override == OVERRIDE_VALUES['earned_graded_override'] + + def test_outsider_is_refused(self): + self.client.force_authenticate(UserFactory()) + assert_error_envelope(self.post(), expected_status=403, expected_type_slug='authz') + assert not PersistentSubsectionGradeOverride.objects.exists() + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class OverrideWriteTest(OverrideTestBase): + """What a valid batch records and returns.""" + + def setUp(self): + super().setUp() + self.login(self.global_staff) + + def test_batch_is_recorded_and_returned_in_order(self): + response = self.post( + self.item(comment='regraded'), + self.item(username='other_student', usage_key=self.second_homework.location, earned_graded_override=1.0), + ) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.json() == {'overrides': [ + {'username': 'student', 'usage_key': str(self.homework.location), **OVERRIDE_VALUES, 'comment': 'regraded'}, + { + 'username': 'other_student', + 'usage_key': str(self.second_homework.location), + **OVERRIDE_VALUES, + 'earned_graded_override': 1.0, + 'comment': None, + }, + ]} + assert self.stored_overrides() == { + (self.student.id, str(self.homework.location)): { + **OVERRIDE_VALUES, 'override_reason': 'regraded', 'system': GradeOverrideFeatureEnum.gradebook, + }, + (self.other_student.id, str(self.second_homework.location)): { + **OVERRIDE_VALUES, + 'earned_graded_override': 1.0, + 'override_reason': None, + 'system': GradeOverrideFeatureEnum.gradebook, + }, + } + + def test_values_left_out_keep_the_computed_grade(self): + response = self.post( + {'username': 'student', 'usage_key': str(self.homework.location), 'earned_graded_override': 1.0}, + ) + assert response.json()['overrides'][0] == { + 'username': 'student', + 'usage_key': str(self.homework.location), + 'earned_all_override': 0.0, + 'possible_all_override': 0.0, + 'earned_graded_override': 1.0, + 'possible_graded_override': 0.0, + 'comment': None, + } + + def test_history_names_the_caller(self): + self.post(self.item()) + history = PersistentSubsectionGradeOverride.history.get() + assert (history.history_user, history.history_type) == (self.global_staff, '+') + + def test_null_clears_an_earlier_override_of_one_value(self): + self.post(self.item()) + response = self.post(self.item(**{**OVERRIDE_VALUES, 'earned_graded_override': None})) + assert response.json()['overrides'][0]['earned_graded_override'] is None + stored, = self.stored_overrides().values() + assert stored['earned_graded_override'] is None + assert stored['possible_graded_override'] == OVERRIDE_VALUES['possible_graded_override'] + + def test_the_course_grade_is_recomputed(self): + """Half marks on one of the twelve homeworks, two of which are dropped, is 0.75% of the course.""" + assert not PersistentCourseGrade.objects.filter(user_id=self.student.id).exists() + self.post(self.item()) + assert PersistentCourseGrade.read(self.student.id, self.course_key).percent_grade == 0.01 + + def test_grades_are_recomputed_after_every_override_is_stored(self): + stored_when_recomputed = [] + + def recompute(**kwargs): + stored_when_recomputed.append(PersistentSubsectionGradeOverride.objects.count()) + + with patch('lms.djangoapps.grades.rest_api.v2.services.recalculate_subsection_grade_v3.apply', + side_effect=recompute): + self.post(self.item(), self.item(usage_key=self.second_homework.location)) + assert stored_when_recomputed == [2, 2] + + def test_failure_while_recomputing_keeps_every_override_and_answers_500(self): + with patch( + 'lms.djangoapps.grades.rest_api.v2.services.recalculate_subsection_grade_v3.apply', + side_effect=RuntimeError('recompute exploded'), + ) as recompute: + response = self.post(self.item(), self.item(username='other_student')) + body = assert_error_envelope(response, expected_status=500, expected_type_slug='internal') + assert 'exploded' not in json.dumps(body) + assert recompute.call_count == 1 + assert set(self.stored_overrides()) == { + (self.student.id, str(self.homework.location)), + (self.other_student.id, str(self.homework.location)), + } + assert not PersistentCourseGrade.objects.filter(user_id=self.student.id).exists() + + retried = self.post(self.item(), self.item(username='other_student')) + assert retried.status_code == status.HTTP_200_OK, retried.content + assert PersistentCourseGrade.read(self.student.id, self.course_key).percent_grade == 0.01 + assert PersistentCourseGrade.read(self.other_student.id, self.course_key).percent_grade == 0.01 + + def test_a_later_item_for_the_same_grade_wins(self): + self.post(self.item(), self.item(earned_graded_override=4.0)) + stored, = self.stored_overrides().values() + assert stored['earned_graded_override'] == 4.0 + + def test_inactive_enrollment_may_still_be_overridden(self): + self.student.courseenrollment_set.filter(course_id=self.course_key).update(is_active=False) + assert self.post(self.item()).status_code == status.HTTP_200_OK + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +@ddt.ddt +class OverrideValidationTest(OverrideTestBase): + """A batch with any invalid item records nothing.""" + + def setUp(self): + super().setUp() + self.login(self.global_staff) + + def refused(self, response): + """Return the ``errors`` member of a validation error body.""" + return assert_error_envelope(response, expected_status=400, expected_type_slug='validation')['errors'] + + def test_invalid_second_item_leaves_the_first_unrecorded(self): + stranger = UserFactory() + grades_before = PersistentSubsectionGrade.objects.count() + with patch('lms.djangoapps.grades.rest_api.v2.services.recalculate_subsection_grade_v3.apply') as recompute: + response = self.post( + self.item(), self.item(username=stranger.username), self.item(username='other_student'), + ) + assert self.refused(response) == { + 'overrides[1].username': ['No learner with this username is enrolled in this course.'], + } + assert self.stored_overrides() == {} + assert PersistentSubsectionGrade.objects.count() == grades_before + assert recompute.call_args_list == [] + + def test_errors_name_every_failed_item(self): + response = self.post( + self.item(username='nobody'), + self.item(), + self.item(usage_key='not-a-key'), + ) + assert self.refused(response) == { + 'overrides[0].username': ['No learner with this username is enrolled in this course.'], + 'overrides[2].usage_key': ['Not a valid usage key.'], + } + + @ddt.data( + 'i4x://edX/DemoX/sequential/basic', + 'block-v1:Other+Course+Run+type@sequential+block@homework', + 'block-v1:{org}+{course}+{run}+type@sequential+block@no_such_block', + ) + def test_usage_key_outside_the_course_is_refused(self, usage_key): + usage_key = usage_key.format(org=self.course_key.org, course=self.course_key.course, run=self.course_key.run) + assert list(self.refused(self.post(self.item(usage_key=usage_key)))) == ['overrides[0].usage_key'] + assert self.stored_overrides() == {} + + @ddt.data( + ({'earned_graded_override': 'lots'}, 'earned_graded_override'), + ({'comment': 'x' * 301}, 'comment'), + ({'username': None}, 'username'), + ) + @ddt.unpack + def test_bad_values_are_refused(self, change, field): + item = {**self.item(), **change} + assert list(self.refused(self.post(item))) == [f'overrides[0].{field}'] + + def test_empty_batch_is_refused(self): + assert self.refused(self.post()) == {'overrides': ['This list may not be empty.']} + + def test_oversized_batch_is_refused(self): + items = [self.item()] * (MAX_OVERRIDES + 1) + assert self.refused(self.post(*items)) == { + 'overrides': [f'Ensure this field has no more than {MAX_OVERRIDES} elements.'], + } + assert self.stored_overrides() == {} + + @ddt.data( + ([{'user_id': 1, 'usage_id': 'x', 'grade': {}}], ['non_field_errors']), + ({}, ['overrides']), + ({'overrides': 'nope'}, ['overrides']), + ({'overrides': ['nope']}, ['overrides[0]']), + ) + @ddt.unpack + def test_malformed_body_is_refused(self, body, paths): + response = self.client.post( + subsection_grade_override_list_url(self.course_key), data=json.dumps(body), content_type='application/json', + ) + assert list(self.refused(response)) == paths + assert self.stored_overrides() == {} + + def test_body_that_is_not_json_is_refused(self): + response = self.client.post( + subsection_grade_override_list_url(self.course_key), data='{not json', content_type='application/json', + ) + # edx-drf-extensions does not catalog ParseError yet, so its type is + # internal. Expect its own slug once the pinned release catalogs it. + assert_error_envelope(response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug='internal') + assert self.stored_overrides() == {} + + def test_form_body_is_unsupported(self): + response = self.client.post( + subsection_grade_override_list_url(self.course_key), + data='overrides=student', content_type='application/x-www-form-urlencoded', + ) + # edx-drf-extensions does not catalog UnsupportedMediaType yet, so its type + # is internal. Expect its own slug once the pinned release catalogs it. + assert_error_envelope( + response, expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, expected_type_slug='internal', + ) + assert self.stored_overrides() == {} + + def test_multipart_body_is_refused_as_empty(self): + """ + A multipart body is read by the site's middleware before the view runs, + so the view finds no body at all and refuses it as missing its overrides. + """ + response = self.client.post( + subsection_grade_override_list_url(self.course_key), data={'overrides': 'student'}, + ) + assert response.wsgi_request.content_type == 'multipart/form-data' + assert self.refused(response) == {'overrides': ['This field is required.']} + assert self.stored_overrides() == {} + + def test_response_that_cannot_be_json_is_not_acceptable(self): + response = self.client.post( + subsection_grade_override_list_url(self.course_key), + data=json.dumps({'overrides': [self.item()]}), content_type='application/json', HTTP_ACCEPT='text/html', + ) + # edx-drf-extensions does not catalog NotAcceptable yet, so its type is + # internal. Expect its own slug once the pinned release catalogs it. + assert_error_envelope(response, expected_status=status.HTTP_406_NOT_ACCEPTABLE, expected_type_slug='internal') + assert self.stored_overrides() == {} + + def test_failure_while_storing_records_nothing(self): + real_store = PersistentSubsectionGradeOverride.update_or_create_override + calls = [] + + def store(*args, **kwargs): + calls.append(1) + if len(calls) == 2: + raise RuntimeError('storage exploded') + return real_store(*args, **kwargs) + + with patch.object(PersistentSubsectionGradeOverride, 'update_or_create_override', side_effect=store): + response = self.post(self.item(), self.item(usage_key=self.second_homework.location)) + body = assert_error_envelope(response, expected_status=500, expected_type_slug='internal') + assert 'exploded' not in json.dumps(body) + assert calls == [1, 1] + assert self.stored_overrides() == {} + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class OverrideParityTest(OverrideTestBase): + """v2 records the same overrides v1 records, and differs from it only as declared.""" + + def setUp(self): + super().setUp() + self.login(self.global_staff) + + def v1_post(self, *items): + return self.client.post( + reverse('grades_api:v1:course_gradebook_bulk_update', kwargs={'course_id': str(self.course_key)}), + data=json.dumps(list(items)), + content_type='application/json', + ) + + def v1_item(self, user, usage_key=None, **grade): + return {'user_id': user.id, 'usage_id': str(usage_key or self.homework.location), 'grade': grade} + + def stored_for(self, user): + """Return the override and recomputed grade values stored for ``user`` on the homework.""" + grade = PersistentSubsectionGrade.read_grade(user.id, self.homework.location) + override_fields = (*OVERRIDE_VALUES, 'override_reason', 'system') + return ( + {name: getattr(grade.override, name) for name in override_fields}, + (grade.earned_all, grade.possible_all, grade.earned_graded, grade.possible_graded), + PersistentCourseGrade.read(user.id, self.course_key).percent_grade, + ) + + def test_same_batch_records_the_same_override(self): + legacy = self.v1_post(self.v1_item(self.student, **OVERRIDE_VALUES, comment='regraded')) + new = self.post(self.item(username='other_student', comment='regraded')) + assert (legacy.status_code, new.status_code) == (status.HTTP_202_ACCEPTED, status.HTTP_200_OK) + assert self.stored_for(self.student) == self.stored_for(self.other_student) + assert self.stored_for(self.other_student)[2] == 0.01 + + def test_mixed_batch_is_applied_in_part_by_v1_and_not_at_all_by_v2(self): + stranger = UserFactory() + legacy = self.v1_post(self.v1_item(self.student, **OVERRIDE_VALUES), self.v1_item(stranger, **OVERRIDE_VALUES)) + assert legacy.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert [item['success'] for item in legacy.json()] == [True, False] + new = self.post(self.item(username='other_student'), self.item(username=stranger.username)) + assert new.status_code == status.HTTP_400_BAD_REQUEST + assert list(self.stored_overrides()) == [(self.student.id, str(self.homework.location))] + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +class OverrideQueryCountTest(OverrideTestBase): + """ + The number of queries a one-item batch issues, against v1 on the same fixture. + + Each side records the override once before it is measured, so both are + measured updating an existing override rather than creating one. + """ + + #: Measured on this fixture; a change is a change in the write path. v2 + #: looks the learner and enrollment up in one query where v1 uses two, and + #: adds the two statements of the transaction the batch is stored in. + ONE_ITEM_QUERIES = 49 + V1_ONE_ITEM_QUERIES = 48 + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def measure(self, url, body): + """Return the number of queries the second POST of ``body`` issues.""" + assert self.client.post(url, data=json.dumps(body), content_type='application/json').status_code < 300 + response, count = count_post_queries(self.client, url, body) + assert response.status_code < 300, response.content + return count + + def test_one_item(self): + legacy = self.measure( + reverse('grades_api:v1:course_gradebook_bulk_update', kwargs={'course_id': str(self.course_key)}), + [{'user_id': self.student.id, 'usage_id': str(self.homework.location), 'grade': OVERRIDE_VALUES}], + ) + new = self.measure( + subsection_grade_override_list_url(self.course_key), {'overrides': [self.item(username='other_student')]}, + ) + assert (new, legacy) == (self.ONE_ITEM_QUERIES, self.V1_ONE_ITEM_QUERIES) + + +@override_waffle_flag(WRITABLE_GRADEBOOK, active=True) +@override_settings(DEBUG=False) +class OverrideUrlTest(OverrideTestBase): + """The address, its name, and the not-found body for addresses no route matches.""" + + COURSE_KEY = 'course-v1:edX+DemoX+Demo_Course' + + def test_reverse_literal(self): + assert reverse('grade_v2:subsection_grade_override_list', kwargs={'course_key': self.COURSE_KEY}) == ( + f'/api/grade/v2/courses/{self.COURSE_KEY}/subsection_grade_overrides/' + ) + + def test_unmatched_addresses_get_a_json_not_found(self): + self.client.force_authenticate(self.global_staff) + for path in ( + '/api/grade/v2/courses/not-a-course-key/subsection_grade_overrides/', + '/api/grade/v2/courses/edX/DemoX/Demo/subsection_grade_overrides/', + f'/api/grade/v2/courses/{self.course_key}/subsection_grade_overrides/extra/', + ): + response = self.client.post(path, data='{}', content_type='application/json') + assert response['Content-Type'] == 'application/json', path + assert_error_envelope(response, expected_status=404, expected_type_slug='not-found') diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grades.py b/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grades.py new file mode 100644 index 000000000000..03b1801e1582 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grades.py @@ -0,0 +1,597 @@ +"""Tests for the subsection grade endpoints of the Grades API v2.""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import ddt +from ccx_keys.locator import CCXLocator +from django.test.utils import override_settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from opaque_keys.edx.locator import BlockUsageLocator +from pytz import UTC +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import AnonymousUserId, CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseLimitedStaffRole, CourseStaffRole +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.ccx.tests.utils import CcxTestCase +from lms.djangoapps.grades.constants import GradeOverrideFeatureEnum +from lms.djangoapps.grades.models import ( + BlockRecord, + BlockRecordList, + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, +) +from lms.djangoapps.grades.rest_api.v1.tests.mixins import GradeViewTestMixin +from lms.djangoapps.grades.rest_api.v2.tests.mixins import ( + AUTO_COHORT, + COHORT_ASSIGNMENT_TABLES, + COHORT_ASSIGNMENT_TRACKING, + PAGE_FIELDS, + CohortedCourseMixin, + ReadOnlyRequestMixin, + capture_cohort_assignment, + count_queries, + subsection_grade_detail_url, + subsection_grade_override_history_url, + warm_anonymous_id, + warm_read_caches, + with_query, +) +from lms.djangoapps.grades.rest_api.v2.tests.parity import assert_parity +from xmodule.modulestore.tests.factories import BlockFactory + +STORED_SCORES = {'earned_all': 6.0, 'possible_all': 12.0, 'earned_graded': 6.0, 'possible_graded': 8.0} +OVERRIDE = { + 'earned_all_override': 10.0, + 'possible_all_override': 12.0, + 'earned_graded_override': 7.0, + 'possible_graded_override': 8.0, +} + + +def store_subsection_grade(user, usage_key, scores=None): + """Store a grade of ``scores`` for ``user`` on the subsection ``usage_key``.""" + scores = scores or STORED_SCORES + problem = BlockUsageLocator(usage_key.course_key, 'problem', 'problem_for_grade') + return PersistentSubsectionGrade.update_or_create_grade( + user_id=user.id, + usage_key=usage_key, + course_version='deadbeef', + subtree_edited_timestamp=datetime(2016, 8, 1, tzinfo=UTC), + visible_blocks=BlockRecordList( + [BlockRecord(locator=problem, weight=1, raw_possible=scores['possible_all'], graded=True)], + usage_key.course_key, + ), + first_attempted=datetime(2000, 1, 1, tzinfo=UTC), + **scores, + ) + + +def override_grade(grade, by, **values): + """Record an override of ``grade`` made by ``by``, as the gradebook does.""" + return PersistentSubsectionGradeOverride.update_or_create_override( + requesting_user=by, + subsection_grade_model=grade, + feature=GradeOverrideFeatureEnum.gradebook, + system=GradeOverrideFeatureEnum.gradebook, + **(values or OVERRIDE), + ) + + +class SubsectionGradeTestBase(GradeViewTestMixin, APITestCase): + """Fixtures shared by the subsection grade endpoint tests.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + chapter = cls.store.get_course(cls.course_key).get_children()[0] + cls.hidden_subsection = BlockFactory.create( + parent_location=chapter.location, + category='sequential', + graded=True, + format='Homework', + visible_to_staff_only=True, + display_name='Hidden homework', + ) + + def setUp(self): + super().setUp() + warm_read_caches(self.course_key) + self.homework = self.store.get_course(self.course_key).get_children()[0].get_children()[0].location + + def login(self, user): + """Sign ``user`` in with a browser session.""" + self.client.login(username=user.username, password=self.password) + + def user_with_role(self, role, course_key=None): + """Return a new user holding ``role`` on ``course_key``, by default the test course.""" + user = UserFactory(password=self.password) + role(course_key or self.course_key).add_users(user) + return user + + def urls(self, username='student', usage_key=None): + """Return the grade and history addresses of ``username`` on a subsection.""" + usage_key = usage_key or self.homework + return ( + subsection_grade_detail_url(username, usage_key), + subsection_grade_override_history_url(username, usage_key), + ) + + +@ddt.ddt +class SubsectionGradeAccessTest(SubsectionGradeTestBase): + """Who may read a subsection grade and its override history.""" + + def test_anonymous_caller_is_refused(self): + for url in self.urls(): + assert_error_envelope(self.client.get(url), expected_status=401, expected_type_slug='authn') + + @ddt.data('student', 'other_student') + def test_learners_are_refused_even_for_their_own_grade(self, caller): + self.login(getattr(self, caller)) + for url in self.urls('student'): + assert_error_envelope(self.client.get(url), expected_status=403, expected_type_slug='authz') + + def test_staff_of_another_course_are_refused(self): + self.login(self.user_with_role(CourseStaffRole, self.empty_course.id)) + for url in self.urls(): + assert_error_envelope(self.client.get(url), expected_status=403, expected_type_slug='authz') + + @ddt.data(CourseStaffRole, CourseInstructorRole, CourseLimitedStaffRole) + def test_course_team_is_admitted(self, role): + self.login(self.user_with_role(role)) + grade_url, history_url = self.urls() + assert self.client.get(grade_url).json()['username'] == 'student' + assert self.client.get(history_url).json()['results'] == [] + + def test_global_staff_are_admitted(self): + self.login(self.global_staff) + for url in self.urls(): + assert self.client.get(url).status_code == status.HTTP_200_OK + + @ddt.data('global_staff_username', 'no_such_user') + def test_learner_not_enrolled_is_not_found(self, username): + self.login(self.global_staff) + username = self.global_staff.username if username == 'global_staff_username' else username + for url in self.urls(username): + assert_error_envelope(self.client.get(url), expected_status=404, expected_type_slug='not-found') + + def test_caller_without_access_cannot_learn_who_is_enrolled(self): + self.login(self.student) + for url in self.urls('no_such_user'): + assert_error_envelope(self.client.get(url), expected_status=403, expected_type_slug='authz') + + def test_inactive_enrollment_is_readable(self): + CourseEnrollment.objects.filter(user=self.student, course_id=self.course_key).update(is_active=False) + self.login(self.global_staff) + for url in self.urls(): + assert self.client.get(url).status_code == status.HTTP_200_OK + + +class SubsectionGradeCcxTest(CcxTestCase, APITestCase): + """ + CCX coaches may read the subsection grades of their learners. + + v1 refuses them, because its subsection endpoint checks course author + access without the CCX branch its gradebook endpoints have. + """ + + def setUp(self): + super().setUp() + self.make_coach() + self.ccx = self.make_ccx() + self.ccx_key = CCXLocator.from_course_locator(self.course.id, str(self.ccx.id)) + self.learner = UserFactory() + CourseEnrollmentFactory(user=self.learner, course_id=self.ccx_key) + block = self.sequentials[0].location + self.usage_key = self.ccx_key.make_usage_key(block.block_type, block.block_id) + store_subsection_grade(self.learner, self.usage_key) + + def get(self, caller): + """Return the grade and history responses ``caller`` gets for the CCX learner.""" + self.client.force_authenticate(caller) + return [ + self.client.get(url) + for url in ( + subsection_grade_detail_url(self.learner.username, self.usage_key), + subsection_grade_override_history_url(self.learner.username, self.usage_key), + ) + ] + + def test_coach_is_admitted(self): + grade, history = self.get(self.coach) + assert (grade.status_code, history.status_code) == (status.HTTP_200_OK, status.HTTP_200_OK) + assert grade.json()['original_grade'] == STORED_SCORES + assert grade.json()['course_key'] == str(self.ccx_key) + + def test_ccx_staff_are_admitted(self): + staff = UserFactory() + CourseStaffRole(self.ccx_key).add_users(staff) + assert [response.status_code for response in self.get(staff)] == [200, 200] + + def test_outsider_is_refused(self): + for response in self.get(UserFactory()): + assert_error_envelope(response, expected_status=403, expected_type_slug='authz') + + def test_coach_is_refused_when_custom_courses_are_off(self): + with override_settings(CUSTOM_COURSES_EDX=False): + responses = self.get(self.coach) + for response in responses: + assert_error_envelope(response, expected_status=403, expected_type_slug='authz') + + def test_v1_refuses_the_coach(self): + self.client.force_authenticate(self.coach) + url = reverse('grades_api:v1:course_grade_overrides', kwargs={'subsection_id': str(self.usage_key)}) + assert self.client.get(with_query(url, user_id=self.learner.id)).status_code == status.HTTP_403_FORBIDDEN + + +class SubsectionGradeContentTest(SubsectionGradeTestBase): + """The grade returned for each kind of stored state.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def grade(self, usage_key=None): + return self.client.get(subsection_grade_detail_url('student', usage_key or self.homework)) + + def test_stored_grade_without_an_override(self): + store_subsection_grade(self.student, self.homework) + assert self.grade().json() == { + 'username': 'student', + 'usage_key': str(self.homework), + 'course_key': str(self.course_key), + 'original_grade': STORED_SCORES, + 'override': None, + } + + def test_stored_grade_with_an_override(self): + override_grade(store_subsection_grade(self.student, self.homework), by=self.global_staff) + body = self.grade().json() + assert (body['original_grade'], body['override']) == (STORED_SCORES, OVERRIDE) + + def test_grade_not_stored_is_computed_and_not_stored(self): + body = self.grade().json() + assert (body['original_grade'], body['override']) == ( + {'earned_all': 0.0, 'possible_all': 0.0, 'earned_graded': 0.0, 'possible_graded': 0.0}, None, + ) + assert not PersistentSubsectionGrade.objects.filter(user_id=self.student.id).exists() + + def test_computed_grade_reports_each_total(self): + computed = MagicMock(all_total=MagicMock(earned=1, possible=2), graded_total=MagicMock(earned=3, possible=4)) + with patch( + 'lms.djangoapps.grades.rest_api.v2.services.SubsectionGradeFactory.create', return_value=computed, + ) as create: + body = self.grade().json() + assert body['original_grade'] == { + 'earned_all': 1.0, 'possible_all': 2.0, 'earned_graded': 3.0, 'possible_graded': 4.0, + } + assert create.call_args.kwargs == {'read_only': True, 'force_calculate': True} + + def test_subsection_the_learner_cannot_see_is_unavailable(self): + assert_error_envelope( + self.grade(self.hidden_subsection.location), + expected_status=404, expected_type_slug='grades/subsection-unavailable', + ) + + def test_block_the_course_does_not_have_is_not_found(self): + missing = BlockUsageLocator(self.course_key, 'sequential', 'no_such_block') + assert_error_envelope(self.grade(missing), expected_status=404, expected_type_slug='not-found') + + +class OverrideHistoryTest(SubsectionGradeTestBase): + """The override history of a subsection grade.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + self.grade = store_subsection_grade(self.student, self.homework) + self.editor = UserFactory(username='editor') + for earned in (1.0, 2.0, 3.0): + override_grade(self.grade, by=self.editor, **{**OVERRIDE, 'earned_graded_override': earned}) + + def history(self, **query): + response = self.client.get(subsection_grade_override_history_url('student', self.homework, **query)) + assert response.status_code == status.HTTP_200_OK, response.content + return response.json() + + def test_records_newest_first(self): + body = self.history() + assert set(body) == PAGE_FIELDS + assert [record['earned_graded_override'] for record in body['results']] == [3.0, 2.0, 1.0] + assert [record['history_type'] for record in body['results']] == ['~', '~', '+'] + newest = body['results'][0] + assert set(newest) == { + 'history_id', 'history_date', 'history_type', 'history_user', 'earned_all_override', + 'possible_all_override', 'earned_graded_override', 'possible_graded_override', + 'override_reason', 'system', + } + assert (newest['history_user'], newest['system'], newest['override_reason']) == ( + 'editor', GradeOverrideFeatureEnum.gradebook, None, + ) + + def test_ordering(self): + oldest_first = self.history(ordering='history_date')['results'] + assert [record['earned_graded_override'] for record in oldest_first] == [1.0, 2.0, 3.0] + + def test_unknown_ordering_is_refused(self): + response = self.client.get(subsection_grade_override_history_url('student', self.homework, ordering='bogus')) + body = assert_error_envelope(response, expected_status=400, expected_type_slug='validation') + assert list(body['errors']) == ['ordering'] + + def test_paging(self): + body = self.history(page_size=2, page=2) + assert (body['count'], body['num_pages'], body['current_page']) == (3, 2, 2) + assert [record['earned_graded_override'] for record in body['results']] == [1.0] + + def test_other_grades_are_left_out(self): + override_grade(store_subsection_grade(self.other_student, self.homework), by=self.editor) + second_homework = self.store.get_course(self.course_key).get_children()[0].get_children()[1].location + override_grade(store_subsection_grade(self.student, second_homework), by=self.editor) + assert self.history()['count'] == 3 + + def test_learner_without_a_stored_grade_has_no_history(self): + response = self.client.get(subsection_grade_override_history_url('other_student', self.homework)) + assert (response.status_code, response.json()['count']) == (status.HTTP_200_OK, 0) + + +class SubsectionGradeParityTest(SubsectionGradeTestBase): + """The v2 bodies equal the v1 body they replace, apart from the declared differences.""" + + GRADE_DIFFERENCES = [ + ('success', 'errors are statuses, not a flag in a 200 body'), + ('history', 'the history is its own paginated list'), + ('user_id', 'database ids are not published'), + ('username', 'the learner is named by username'), + ] + HISTORY_DIFFERENCES = [ + ('[*].created', 'the creation date of the override is not a change record field'), + ('[*].grade_id', 'database ids are not published'), + ('[*].history_user_id', 'database ids are not published'), + ('[*].id', 'database ids are not published'), + ] + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def v1(self, usage_key=None, **query): + url = reverse('grades_api:v1:course_grade_overrides', kwargs={'subsection_id': str(usage_key or self.homework)}) + response = self.client.get(with_query(url, user_id=self.student.id, **query)) + assert response.status_code == status.HTTP_200_OK + return response.json() + + def v2(self, usage_key=None): + response = self.client.get(subsection_grade_detail_url('student', usage_key or self.homework)) + return response + + def test_stored_grade_with_an_override(self): + grade = store_subsection_grade(self.student, self.homework) + override_grade(grade, by=self.global_staff) + assert_parity( + self.v1(), self.v2().json(), + renames={'subsection_id': 'usage_key', 'course_id': 'course_key'}, + differences=self.GRADE_DIFFERENCES, + ) + + def test_grade_not_stored(self): + assert_parity( + self.v1(), self.v2().json(), + renames={'subsection_id': 'usage_key', 'course_id': 'course_key'}, + differences=self.GRADE_DIFFERENCES, + ) + + def test_unavailable_subsection_is_a_200_in_v1_and_a_404_in_v2(self): + legacy = self.v1(self.hidden_subsection.location) + assert (legacy['success'], legacy['original_grade']['possible_all']) == (False, 0.0) + assert self.v2(self.hidden_subsection.location).status_code == status.HTTP_404_NOT_FOUND + + def test_history(self): + grade = store_subsection_grade(self.student, self.homework) + for earned in (1.0, 2.0): + override_grade(grade, by=self.global_staff, **{**OVERRIDE, 'earned_graded_override': earned}) + legacy = self.v1()['history'] + new = self.client.get( + subsection_grade_override_history_url('student', self.homework, ordering='history_date'), + ).json()['results'] + assert_parity(legacy, new, differences=self.HISTORY_DIFFERENCES) + + def test_limited_history_is_the_same_records_newest_first(self): + grade = store_subsection_grade(self.student, self.homework) + for earned in range(7): + override_grade(grade, by=self.global_staff, **{**OVERRIDE, 'earned_graded_override': float(earned)}) + legacy = [record['history_id'] for record in self.v1(history_record_limit=5)['history']] + new = [ + record['history_id'] for record in self.client.get( + subsection_grade_override_history_url('student', self.homework, page_size=5), + ).json()['results'] + ] + assert len(new) == 5 + assert new == list(reversed(legacy)) + + +class SubsectionGradeReadOnlyTest(ReadOnlyRequestMixin, SubsectionGradeTestBase): + """Reading a subsection grade or its history changes nothing and announces nothing.""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def test_stored_grade(self): + override_grade(store_subsection_grade(self.student, self.homework), by=self.global_staff) + with self.assert_nothing_is_written(): + for url in self.urls(): + assert self.client.get(url).status_code == status.HTTP_200_OK + + def test_computed_grade(self): + warm_anonymous_id(self.student, self.course_key) + with self.assert_nothing_is_written(): + assert self.client.get(self.urls()[0]).status_code == status.HTTP_200_OK + + def test_head(self): + warm_anonymous_id(self.student, self.course_key) + with self.assert_nothing_is_written(): + for url in self.urls(): + assert self.client.head(url).status_code == status.HTTP_200_OK + + +class ComputedGradeAnonymousIdTest(SubsectionGradeTestBase): + """ + The first computed grade of a learner in a course stores the learner's anonymous id there. + + Computing a grade the learner has no stored grade for reads their + submissions, and the shared lookup of the anonymous id those are keyed by + stores the id the first time it is asked for it. v1 behaves the same. This + pins the behaviour so a change to either side is deliberate. + """ + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def anonymous_ids(self): + return AnonymousUserId.objects.filter(user=self.student, course_id=self.course_key).count() + + def test_first_computed_read_stores_the_id_and_later_reads_do_not(self): + assert self.anonymous_ids() == 0 + assert self.client.get(self.urls()[0]).status_code == status.HTTP_200_OK + assert self.anonymous_ids() == 1 + assert self.client.get(self.urls()[0]).status_code == status.HTTP_200_OK + assert self.anonymous_ids() == 1 + + def test_stored_grade_read_stores_nothing(self): + store_subsection_grade(self.student, self.homework) + assert self.client.get(self.urls()[0]).status_code == status.HTTP_200_OK + assert self.anonymous_ids() == 0 + + def test_v1_does_the_same(self): + url = reverse('grades_api:v1:course_grade_overrides', kwargs={'subsection_id': str(self.homework)}) + assert self.client.get(with_query(url, user_id=self.student.id)).status_code == status.HTTP_200_OK + assert self.anonymous_ids() == 1 + + +class SubsectionGradeCohortAssignmentTest(CohortedCourseMixin, SubsectionGradeTestBase): + """ + On a course whose content groups follow cohorts, the first computed + subsection grade of a learner who is in no cohort assigns them to one, as + v1 does; a stored grade and the override history do not. + + Computing a grade collects the course content the learner can see, which + asks for the learner's content group, and the cohort lookup behind it + assigns a cohort to a learner who has none, storing the membership and + announcing it. This pins both sides so a change to either is deliberate. + """ + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + + def capture(self, url): + with capture_cohort_assignment() as record: + assert self.client.get(url).status_code == status.HTTP_200_OK + return record + + def assert_assigned_once(self, url): + first = self.capture(url) + assert first.tables == COHORT_ASSIGNMENT_TABLES + assert first.memberships == [('uncohorted', AUTO_COHORT)] + assert first.tracking == COHORT_ASSIGNMENT_TRACKING + assert self.cohort_of(self.uncohorted) == AUTO_COHORT + second = self.capture(url) + assert (second.tables, second.memberships, second.tracking) == ([], [], []) + + def test_computed_grade_assigns_the_learner(self): + self.assert_assigned_once(self.urls('uncohorted')[0]) + + def assert_not_assigned(self, url): + record = self.capture(url) + assert (record.tables, record.memberships, record.tracking) == ([], [], []) + assert self.cohort_of(self.uncohorted) is None + + def test_stored_grade_assigns_nobody(self): + store_subsection_grade(self.uncohorted, self.homework) + self.assert_not_assigned(self.urls('uncohorted')[0]) + + def test_history_assigns_nobody(self): + override_grade(store_subsection_grade(self.uncohorted, self.homework), by=self.global_staff) + self.assert_not_assigned(self.urls('uncohorted')[1]) + + def test_v1_assigns_the_learner_too(self): + url = reverse('grades_api:v1:course_grade_overrides', kwargs={'subsection_id': str(self.homework)}) + self.assert_assigned_once(with_query(url, user_id=self.uncohorted.id)) + + +class SubsectionGradeQueryCountTest(SubsectionGradeTestBase): + """ + Each v2 read issues no more queries than the one v1 read it replaces, on the + same stored grade with three override records. + + Each address is requested once before it is measured. + """ + + #: Measured on this fixture; a change is a change in the data path. + GRADE_QUERIES = 9 + HISTORY_QUERIES = 10 + V1_QUERIES = 12 + + def setUp(self): + super().setUp() + self.client.force_authenticate(self.global_staff) + grade = store_subsection_grade(self.student, self.homework) + for earned in (1.0, 2.0, 3.0): + override_grade(grade, by=self.global_staff, **{**OVERRIDE, 'earned_graded_override': earned}) + + def measure(self, url): + assert self.client.get(url).status_code == status.HTTP_200_OK + response, count = count_queries(self.client, url) + assert response.status_code == status.HTTP_200_OK + return count + + def test_counts(self): + v1_url = reverse('grades_api:v1:course_grade_overrides', kwargs={'subsection_id': str(self.homework)}) + legacy = self.measure(with_query(v1_url, user_id=self.student.id)) + grade_url, history_url = self.urls() + grade, history = self.measure(grade_url), self.measure(history_url) + assert (grade, history, legacy) == (self.GRADE_QUERIES, self.HISTORY_QUERIES, self.V1_QUERIES) + assert max(grade, history) <= legacy + + def test_history_count_does_not_grow_with_the_page_size(self): + _, history_url = self.urls() + assert self.measure(with_query(history_url, page_size=1)) == self.measure(with_query(history_url, page_size=3)) + + +@override_settings(DEBUG=False) +class SubsectionGradeUrlTest(SubsectionGradeTestBase): + """The addresses, their names, and the not-found body for addresses no route matches.""" + + USAGE_KEY = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions' + + def test_reverse_literals(self): + kwargs = {'username': 'u', 'usage_key': self.USAGE_KEY} + assert reverse('grade_v2:subsection_grade_detail', kwargs=kwargs) == ( + f'/api/grade/v2/subsection_grades/u,{self.USAGE_KEY}/' + ) + assert reverse('grade_v2:subsection_grade_override_history_list', kwargs=kwargs) == ( + f'/api/grade/v2/subsection_grades/u,{self.USAGE_KEY}/override_history_records/' + ) + + def test_unmatched_addresses_get_a_json_not_found(self): + self.client.force_authenticate(self.global_staff) + for path in ( + '/api/grade/v2/subsection_grades/student,not-a-usage-key/', + '/api/grade/v2/subsection_grades/student,i4x://edX/DemoX/sequential/basic/', + '/api/grade/v2/subsection_grades/student/', + '/api/grade/v2/subsection_grades/student,not-a-usage-key/override_history_records/', + f'/api/grade/v2/subsection_grades/student,{self.homework}/override_history_records/extra/', + ): + response = self.client.get(path) + assert response['Content-Type'] == 'application/json', path + assert_error_envelope(response, expected_status=404, expected_type_slug='not-found') + + def test_missing_trailing_slash_redirects(self): + response = self.client.get(f'/api/grade/v2/subsection_grades/student,{self.homework}') + assert response.status_code == status.HTTP_301_MOVED_PERMANENTLY + assert response['Location'].endswith(f'/api/grade/v2/subsection_grades/student,{self.homework}/') diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_urls.py b/lms/djangoapps/grades/rest_api/v2/tests/test_urls.py new file mode 100644 index 000000000000..70e48cc5e06c --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_urls.py @@ -0,0 +1,217 @@ +"""Tests for the Grades API URL names and the v1 addresses v2 supersedes.""" + +import ddt +from django.test import SimpleTestCase, TestCase +from django.test.utils import override_settings +from django.urls import get_resolver, resolve, reverse +from edx_rest_framework_extensions.testing import assert_error_envelope + +from lms.djangoapps.grades.rest_api.v1 import gradebook_views, views +from lms.djangoapps.grades.rest_api.v2.views.unmatched import UnmatchedRouteView + +COURSE_ID = 'course-v1:edX+DemoX+Demo_Course' +SUBSECTION_ID = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@a_subsection' +LEGACY_PREFIX = '/api/grades/v1/' + + +class SupersededAddressTest(SimpleTestCase): + """Every superseded address still reaches the handler it always reached.""" + + def assert_resolves_to(self, path, view_class, url_name): + """Fail unless ``path`` is served by ``view_class`` under ``url_name``.""" + match = resolve(path) + assert match.func.view_class is view_class + assert match.url_name == url_name + + def test_course_grade_collection(self): + self.assert_resolves_to(f'{LEGACY_PREFIX}courses/', views.CourseGradesView, 'course_grades') + + def test_course_grade_collection_of_one_course(self): + self.assert_resolves_to( + f'{LEGACY_PREFIX}courses/{COURSE_ID}/', views.CourseGradesView, 'course_grades', + ) + + def test_both_course_grade_addresses_share_one_name(self): + assert reverse('grades_api:v1:course_grades') == f'{LEGACY_PREFIX}courses/' + assert reverse('grades_api:v1:course_grades', kwargs={'course_id': COURSE_ID}) == \ + f'{LEGACY_PREFIX}courses/{COURSE_ID}/' + + def test_grading_policy(self): + self.assert_resolves_to( + f'{LEGACY_PREFIX}policy/courses/{COURSE_ID}/', + views.CourseGradingPolicy, + 'course_grading_policy', + ) + + def test_gradebook(self): + self.assert_resolves_to( + f'{LEGACY_PREFIX}gradebook/{COURSE_ID}/', + gradebook_views.GradebookView, + 'course_gradebook', + ) + + def test_gradebook_bulk_update(self): + self.assert_resolves_to( + f'{LEGACY_PREFIX}gradebook/{COURSE_ID}/bulk-update', + gradebook_views.GradebookBulkUpdateView, + 'course_gradebook_bulk_update', + ) + + def test_gradebook_grading_info(self): + self.assert_resolves_to( + f'{LEGACY_PREFIX}gradebook/{COURSE_ID}/grading-info', + gradebook_views.CourseGradingView, + 'course_gradebook_grading_info', + ) + + def test_subsection_grade_overrides(self): + path = f'{LEGACY_PREFIX}subsection/{SUBSECTION_ID}/' + self.assert_resolves_to(path, gradebook_views.SubsectionGradeView, 'course_grade_overrides') + assert resolve(path).kwargs['subsection_id'] == SUBSECTION_ID + + def test_subsection_address_accepts_any_identifier(self): + """The subsection identifier is captured unconstrained, slashes included.""" + path = f'{LEGACY_PREFIX}subsection/anything at all/with a slash/' + assert resolve(path).kwargs['subsection_id'] == 'anything at all/with a slash' + + def test_section_grades_breakdown(self): + self.assert_resolves_to( + f'{LEGACY_PREFIX}section_grades_breakdown/', + views.SectionGradesBreakdown, + 'section_grades_breakdown', + ) + + def test_submission_history(self): + self.assert_resolves_to( + f'{LEGACY_PREFIX}submission_history/{COURSE_ID}/', + views.SubmissionHistoryView, + 'submission_history', + ) + + def test_submission_history_matches_anywhere_in_the_address(self): + """The submission-history address is unanchored, so text may precede and follow it.""" + self.assert_resolves_to( + f'{LEGACY_PREFIX}anything-submission_history/{COURSE_ID}/and-anything-else', + views.SubmissionHistoryView, + 'submission_history', + ) + + def test_every_superseded_name_still_reverses(self): + assert reverse('grades_api:v1:section_grades_breakdown') == f'{LEGACY_PREFIX}section_grades_breakdown/' + assert reverse('grades_api:v1:course_gradebook', kwargs={'course_id': COURSE_ID}) == \ + f'{LEGACY_PREFIX}gradebook/{COURSE_ID}/' + assert reverse('grades_api:v1:course_gradebook_bulk_update', kwargs={'course_id': COURSE_ID}) == \ + f'{LEGACY_PREFIX}gradebook/{COURSE_ID}/bulk-update' + assert reverse('grades_api:v1:course_gradebook_grading_info', kwargs={'course_id': COURSE_ID}) == \ + f'{LEGACY_PREFIX}gradebook/{COURSE_ID}/grading-info' + assert reverse('grades_api:v1:course_grading_policy', kwargs={'course_id': COURSE_ID}) == \ + f'{LEGACY_PREFIX}policy/courses/{COURSE_ID}/' + assert reverse('grades_api:v1:course_grade_overrides', kwargs={'subsection_id': SUBSECTION_ID}) == \ + f'{LEGACY_PREFIX}subsection/{SUBSECTION_ID}/' + assert reverse('grades_api:v1:submission_history', kwargs={'course_id': COURSE_ID}) == \ + f'{LEGACY_PREFIX}submission_history/{COURSE_ID}/' + + +class GradeV2UrlNameTest(SimpleTestCase): + """Every v2 route has a name of its own.""" + + def test_names_are_unique(self): + _, v2_resolver = get_resolver().namespace_dict['grade_v2'] + names = [pattern.name for pattern in v2_resolver.url_patterns] + assert len(names) == len(set(names)) + assert all(name and name == name.lower() and '-' not in name for name in names) + + def test_every_operation_has_its_name(self): + _, v2_resolver = get_resolver().namespace_dict['grade_v2'] + names = {pattern.name for pattern in v2_resolver.url_patterns if not pattern.name.endswith('_unmatched')} + assert names == { + 'course_grade_list', + 'course_grade_detail', + 'gradebook_entry_list', + 'gradebook_entry_detail', + 'subsection_grade_override_list', + 'subsection_grade_detail', + 'subsection_grade_override_history_list', + 'course_grading_policy', + 'submission_history_list', + } + + +class GradeV2ResolutionTest(SimpleTestCase): + """Every v2 address resolves to its view with parsed keys, and each unmatched one to the not-found view.""" + + USAGE_KEY = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@a_subsection' + PREFIX = '/api/grade/v2/' + + def test_addresses_resolve_to_their_views(self): + expected = { + f'courses/{COURSE_ID}/subsection_grade_overrides/': ('SubsectionGradeOverrideViewSet', 'create'), + f'subsection_grades/u,{self.USAGE_KEY}/': ('SubsectionGradeViewSet', 'retrieve'), + f'subsection_grades/u,{self.USAGE_KEY}/override_history_records/': ( + 'SubsectionGradeOverrideHistoryViewSet', 'list', + ), + f'courses/{COURSE_ID}/grading_policy/': ('CourseGradingPolicyView', None), + f'courses/{COURSE_ID}/submission_histories/': ('SubmissionHistoryViewSet', 'list'), + } + for path, (view_name, action) in expected.items(): + match = resolve(self.PREFIX + path) + assert _view_class(match).__name__ == view_name, path + assert (getattr(match.func, 'actions', None) or {}).get('post' if action == 'create' else 'get') == action + match = resolve(f'{self.PREFIX}subsection_grades/u,{self.USAGE_KEY}/') + assert (str(match.kwargs['usage_key']), match.kwargs['username']) == (self.USAGE_KEY, 'u') + + def test_no_route_is_shadowed_by_a_not_found_route(self): + kwargs = { + 'course_grade_list': {}, + 'course_grade_detail': {'username': 'u', 'course_key': COURSE_ID}, + 'gradebook_entry_list': {'course_key': COURSE_ID}, + 'gradebook_entry_detail': {'course_key': COURSE_ID, 'username': 'u'}, + 'subsection_grade_override_list': {'course_key': COURSE_ID}, + 'subsection_grade_detail': {'username': 'u', 'usage_key': self.USAGE_KEY}, + 'subsection_grade_override_history_list': {'username': 'u', 'usage_key': self.USAGE_KEY}, + 'course_grading_policy': {'course_key': COURSE_ID}, + 'submission_history_list': {'course_key': COURSE_ID}, + } + resolved = {name: resolve(reverse(f'grade_v2:{name}', kwargs=values)) for name, values in kwargs.items()} + assert {name: match.url_name for name, match in resolved.items()} == {name: name for name in kwargs} + assert [name for name, match in resolved.items() if _view_class(match) is UnmatchedRouteView] == [] + + def test_rejected_keys_reach_the_not_found_view(self): + for path in ( + 'courses/edX/DemoX/Demo/subsection_grade_overrides/', + 'courses/not-a-key/grading_policy/', + 'courses/not-a-key/submission_histories/', + 'subsection_grades/u,not-a-key/', + 'subsection_grades/u,not-a-key/override_history_records/', + ): + assert _view_class(resolve(self.PREFIX + path)).__name__ == 'UnmatchedRouteView', path + + +def _view_class(match): + """Return the view class a resolved address is served by, for ViewSets and plain views alike.""" + return getattr(match.func, 'cls', None) or match.func.view_class + + +@ddt.ddt +@override_settings(DEBUG=False) +class UnmatchedAddressTest(TestCase): + """Addresses under the v2 mount that name no resource get the JSON not-found error, whatever the method.""" + + ADDRESSES = ( + '/api/grade/v2/', + '/api/grade/v2/subsection_grades/', + '/api/grade/v2/courses/', + f'/api/grade/v2/courses/{COURSE_ID}/', + f'/api/grade/v2/courses/{COURSE_ID}/no_such_collection/', + f'/api/grade/v2/courses/{COURSE_ID}/gradebook_entries/u/extra/', + '/api/grade/v2/courses/not-a-key/gradebook_entries/', + '/api/grade/v2/courses/edX/DemoX/Demo/grading_policy/', + ) + + @ddt.data('get', 'post') + def test_json_not_found(self, method): + for path in self.ADDRESSES: + response = getattr(self.client, method)(path) + assert response['Content-Type'] == 'application/json', (method, path) + body = assert_error_envelope(response, expected_status=404, expected_type_slug='not-found') + assert body['instance'] == path diff --git a/lms/djangoapps/grades/rest_api/v2/urls.py b/lms/djangoapps/grades/rest_api/v2/urls.py new file mode 100644 index 000000000000..9b150f652934 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/urls.py @@ -0,0 +1,73 @@ +"""Grades API v2 URLs.""" + +from django.urls import path + +from lms.djangoapps.grades.rest_api.v2.views.course_grades import CourseGradeViewSet +from lms.djangoapps.grades.rest_api.v2.views.course_grading_policy import CourseGradingPolicyView +from lms.djangoapps.grades.rest_api.v2.views.gradebook_entries import GradebookEntryViewSet +from lms.djangoapps.grades.rest_api.v2.views.submission_histories import SubmissionHistoryViewSet +from lms.djangoapps.grades.rest_api.v2.views.subsection_grade_overrides import SubsectionGradeOverrideViewSet +from lms.djangoapps.grades.rest_api.v2.views.subsection_grades import ( + SubsectionGradeOverrideHistoryViewSet, + SubsectionGradeViewSet, +) +from lms.djangoapps.grades.rest_api.v2.views.unmatched import UnmatchedRouteView + +app_name = 'grade_v2' + +urlpatterns = [ + path( + 'course_grades/', + CourseGradeViewSet.as_view({'get': 'list'}), + name='course_grade_list', + ), + path( + 'course_grades/,/', + CourseGradeViewSet.as_view({'get': 'retrieve'}), + name='course_grade_detail', + ), + path( + 'subsection_grades/,/', + SubsectionGradeViewSet.as_view({'get': 'retrieve'}), + name='subsection_grade_detail', + ), + path( + 'subsection_grades/,/override_history_records/', + SubsectionGradeOverrideHistoryViewSet.as_view({'get': 'list'}), + name='subsection_grade_override_history_list', + ), + path( + 'courses//gradebook_entries/', + GradebookEntryViewSet.as_view({'get': 'list'}), + name='gradebook_entry_list', + ), + path( + 'courses//gradebook_entries//', + GradebookEntryViewSet.as_view({'get': 'retrieve'}), + name='gradebook_entry_detail', + ), + path( + 'courses//subsection_grade_overrides/', + SubsectionGradeOverrideViewSet.as_view({'post': 'create'}), + name='subsection_grade_override_list', + ), + path( + 'courses//grading_policy/', + CourseGradingPolicyView.as_view(), + name='course_grading_policy', + ), + path( + 'courses//submission_histories/', + SubmissionHistoryViewSet.as_view({'get': 'list'}), + name='submission_history_list', + ), + # Addresses that no route above matches, such as one with a malformed or + # retired key, get a JSON not-found error rather than the site's HTML page. + # They must stay after every real route, which would otherwise be shadowed. + path('', UnmatchedRouteView.as_view(), name='root_unmatched'), + path('course_grades//', UnmatchedRouteView.as_view(), name='course_grade_unmatched'), + path('subsection_grades/', UnmatchedRouteView.as_view(), name='subsection_grade_list_unmatched'), + path('subsection_grades//', UnmatchedRouteView.as_view(), name='subsection_grade_unmatched'), + path('courses/', UnmatchedRouteView.as_view(), name='course_list_unmatched'), + path('courses//', UnmatchedRouteView.as_view(), name='course_unmatched'), +] diff --git a/lms/djangoapps/grades/rest_api/v2/views/__init__.py b/lms/djangoapps/grades/rest_api/v2/views/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/grades/rest_api/v2/views/course_grades.py b/lms/djangoapps/grades/rest_api/v2/views/course_grades.py new file mode 100644 index 000000000000..269ef972d658 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/views/course_grades.py @@ -0,0 +1,178 @@ +"""Course grade endpoints for the Grades API v2.""" + +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.scoping import ScopedQuerysetMixin +from rest_framework import mixins, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.response import Response + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.util.query import use_read_replica_if_available +from lms.djangoapps.grades.rest_api.v2.filters import CourseGradeFilterSet +from lms.djangoapps.grades.rest_api.v2.pagination import GradePagination +from lms.djangoapps.grades.rest_api.v2.permissions import COURSE_GRADE_READ_ACCESS, HasGradeBreakdownAccess +from lms.djangoapps.grades.rest_api.v2.scoping import CourseGradeScopingPolicy +from lms.djangoapps.grades.rest_api.v2.serializers import CourseGradeQuerySerializer, CourseGradeSerializer +from lms.djangoapps.grades.rest_api.v2.services import course_grade_row, course_grade_rows + +SDK_TAG = 'openedx-platform-sdk' +FULL_VIEW = 'full' + +_VIEW_PARAMETER = OpenApiParameter( + name='view', + description=( + "Send 'full' to include the breakdown of each grade by subsection and assignment type. " + "Only global staff may ask for it." + ), + required=False, + type=str, + enum=[FULL_VIEW], + location=OpenApiParameter.QUERY, +) +_USERNAME_PATH_PARAMETER = OpenApiParameter( + name='username', + description='Username of the learner whose grade to return.', + required=True, + type=str, + location=OpenApiParameter.PATH, +) +_COURSE_KEY_PATH_PARAMETER = OpenApiParameter( + name='course_key', + description='Opaque key of the course the grade belongs to.', + required=True, + type=str, + location=OpenApiParameter.PATH, +) +_BAD_REQUEST = OpenApiResponse( + response=ErrorResponseSerializer, description='A query parameter is not valid.', +) +_UNAUTHENTICATED = OpenApiResponse( + response=ErrorResponseSerializer, description='The caller is not authenticated.', +) +_FORBIDDEN = OpenApiResponse( + response=ErrorResponseSerializer, + description=( + 'The caller authenticated with a restricted token that lacks the grades:read scope, ' + 'or asked for the full view without being global staff.' + ), +) +_NOT_FOUND = OpenApiResponse( + response=ErrorResponseSerializer, + description='No enrollment of this learner in this course is visible to the caller.', +) + + +@extend_schema_view( + list=extend_schema( + operation_id='course_grades_list', + tags=[SDK_TAG], + summary='List course grades', + description=( + "Returns one grade per active enrollment the caller may read. Learners see their own " + "grades in every course they are enrolled in; global staff see every learner's grades; " + "a restricted token sees the organizations and learners its filters name. Without a " + "course_key the list spans every course. Only global staff, not through a restricted " + "token, may ask for the full view. A grade that cannot be computed is left out of the " + "page, but count and num_pages still include it, so a page may hold fewer rows than " + "page_size." + ), + parameters=[_VIEW_PARAMETER], + responses={ + 200: CourseGradeSerializer, + 400: _BAD_REQUEST, + 401: _UNAUTHENTICATED, + 403: _FORBIDDEN, + }, + ), + retrieve=extend_schema( + operation_id='course_grades_retrieve', + tags=[SDK_TAG], + summary="Get a learner's course grade", + description=( + "Returns one learner's grade in one course. The enrollment need not be active. " + "Learners may read their own grade; global staff may read anyone's. Only global staff, " + "not through a restricted token, may ask for the full view." + ), + parameters=[_USERNAME_PATH_PARAMETER, _COURSE_KEY_PATH_PARAMETER, _VIEW_PARAMETER], + responses={ + 200: CourseGradeSerializer, + 400: _BAD_REQUEST, + 401: _UNAUTHENTICATED, + 403: _FORBIDDEN, + 404: _NOT_FOUND, + }, + ), +) +class CourseGradeViewSet( + StandardizedErrorMixin, + ScopedQuerysetMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """ + Course grades, one per learner and course. + + A grade is identified by the learner and the course together, so the member + address carries both. The rows are enrollments, which DRF scopes, filters + and pages; the grade values themselves are computed by the grades app for + each page rather than read from the enrollment. + + The collection lists active enrollments only. The member address also finds + an inactive enrollment, so a learner who unenrolled can still read the + grade they earned. The full view is refused to all but global staff, + whatever rows the caller may otherwise read. + """ + + permission_classes = (COURSE_GRADE_READ_ACCESS, HasGradeBreakdownAccess) + required_scopes = ['grades:read'] + serializer_class = CourseGradeSerializer + pagination_class = GradePagination + filter_backends = (DjangoFilterBackend,) + filterset_class = CourseGradeFilterSet + queryset = CourseEnrollment.objects.select_related('user') + + @property + def scoping_policy(self): + """Return the record-visibility policy for this request.""" + return CourseGradeScopingPolicy(self.request) + + def get_queryset(self): + """Return the enrollments whose grades the caller may read, in a stable order.""" + queryset = super().get_queryset() + if self.action == 'list': + queryset = queryset.filter(is_active=True) + return use_read_replica_if_available(queryset).order_by('id') + + def get_object(self): + """Return the enrollment addressed by the learner and course in the URL.""" + enrollment = self.get_queryset().filter( + user__username=self.kwargs['username'], + course_id=self.kwargs['course_key'], + ).first() + if enrollment is None: + raise NotFound() + self.check_object_permissions(self.request, enrollment) + return enrollment + + def list(self, request, *args, **kwargs): + """Return one page of course grades.""" + with_breakdown = self.breakdown_requested() + page = self.paginate_queryset(self.filter_queryset(self.get_queryset())) + rows = course_grade_rows(page, with_section_breakdown=with_breakdown) + return self.get_paginated_response(self.get_serializer(rows, many=True).data) + + def retrieve(self, request, *args, **kwargs): + """Return one learner's grade in one course.""" + with_breakdown = self.breakdown_requested() + row = course_grade_row(self.get_object(), with_section_breakdown=with_breakdown) + return Response(self.get_serializer(row).data) + + def breakdown_requested(self): + """Validate the requested representation and return True when it is the full one.""" + query = CourseGradeQuerySerializer(data=self.request.query_params) + query.is_valid(raise_exception=True) + return query.validated_data.get('view') == FULL_VIEW diff --git a/lms/djangoapps/grades/rest_api/v2/views/course_grading_policy.py b/lms/djangoapps/grades/rest_api/v2/views/course_grading_policy.py new file mode 100644 index 000000000000..4f97f1b7c5c0 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/views/course_grading_policy.py @@ -0,0 +1,100 @@ +"""Course grading policy endpoint for the Grades API v2.""" + +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.shaping import MinimalViewMixin +from rest_framework.response import Response +from rest_framework.views import APIView + +from lms.djangoapps.grades.rest_api.v2.permissions import ( + COURSE_GRADING_POLICY_READ_ACCESS, + CourseExists, + HasGradebookAccessUnlessMinimal, +) +from lms.djangoapps.grades.rest_api.v2.serializers import ( + CourseGradingPolicyQuerySerializer, + CourseGradingPolicySerializer, +) +from lms.djangoapps.grades.rest_api.v2.services import course_grading_policy + +SDK_TAG = 'openedx-platform-sdk' + + +class CourseGradingPolicyView(StandardizedErrorMixin, MinimalViewMixin, APIView): + """ + How one course is graded: its grade cutoffs, assignment types and subsections. + + A course has exactly one grading policy, so the address carries no + identifier of its own and there is no collection to list; that is why this + is an APIView. Course staff, and anyone who may manage the gradebook of the + course, may read the minimal view; the full view is for those who may + manage the gradebook only. The minimal view reads the course content one + level deep only, since it leaves the subsections out. + """ + + permission_classes = (COURSE_GRADING_POLICY_READ_ACCESS, HasGradebookAccessUnlessMinimal, CourseExists) + serializer_class = CourseGradingPolicySerializer + minimal_fields = ('grade_cutoffs', 'assignment_types') + + def get_course_key(self): + """Return the course the request addresses.""" + return self.kwargs['course_key'] + + @extend_schema( + operation_id='course_grading_policy_retrieve', + tags=[SDK_TAG], + summary='Get the grading policy of a course', + description=( + 'Returns the grade cutoffs of the course, the assignment types it grades against, ' + 'and the subsections learners can see. Course staff may read the minimal view; the ' + 'full view requires access to manage the gradebook of the course.' + ), + parameters=[ + OpenApiParameter( + name='course_key', + description='Opaque key of the course.', + required=True, + type=str, + location=OpenApiParameter.PATH, + ), + OpenApiParameter( + name='view', + description="Send 'minimal' for the grade cutoffs and assignment types only.", + required=False, + type=str, + enum=['minimal'], + location=OpenApiParameter.QUERY, + ), + OpenApiParameter( + name='graded_only', + description='Send true to leave the subsections that carry no grade out.', + required=False, + type=bool, + location=OpenApiParameter.QUERY, + ), + ], + responses={ + 200: CourseGradingPolicySerializer, + 400: OpenApiResponse(response=ErrorResponseSerializer, description='A query parameter is not valid.'), + 401: OpenApiResponse(response=ErrorResponseSerializer, description='The caller is not authenticated.'), + 403: OpenApiResponse( + response=ErrorResponseSerializer, + description=( + 'The caller may not manage the gradebook of this course and, for the minimal view, ' + 'is not course staff either.' + ), + ), + 404: OpenApiResponse(response=ErrorResponseSerializer, description='The course does not exist.'), + }, + ) + def get(self, request, course_key): + """Return the grading policy of one course.""" + query = CourseGradingPolicyQuerySerializer(data=request.query_params) + query.is_valid(raise_exception=True) + policy = course_grading_policy( + course_key, + with_subsections=not self.is_minimal_view_requested(request), + graded_only=query.validated_data['graded_only'], + ) + return Response(self.shape_minimal(self.serializer_class(policy).data, request)) diff --git a/lms/djangoapps/grades/rest_api/v2/views/gradebook_entries.py b/lms/djangoapps/grades/rest_api/v2/views/gradebook_entries.py new file mode 100644 index 000000000000..2fc88ee526ea --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/views/gradebook_entries.py @@ -0,0 +1,165 @@ +"""Gradebook endpoints for the Grades API v2.""" + +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.scoping import FullScopePolicy, ScopedQuerysetMixin +from edx_rest_framework_extensions.shaping import MinimalViewMixin +from rest_framework import mixins, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.util.query import use_read_replica_if_available +from lms.djangoapps.grades.rest_api.v2.filters import GradebookEntryFilterSet +from lms.djangoapps.grades.rest_api.v2.pagination import GradePagination +from lms.djangoapps.grades.rest_api.v2.permissions import CourseExists, HasGradebookAccess, WritableGradebookEnabled +from lms.djangoapps.grades.rest_api.v2.serializers import GradebookEntryQuerySerializer, GradebookEntrySerializer +from lms.djangoapps.grades.rest_api.v2.services import gradebook_entry_row, gradebook_entry_rows + +TAG = 'grade' + +_COURSE_KEY_PATH_PARAMETER = OpenApiParameter( + name='course_key', + description='Opaque key of the course whose gradebook to read.', + required=True, + type=str, + location=OpenApiParameter.PATH, +) +_USERNAME_PATH_PARAMETER = OpenApiParameter( + name='username', + description='Username of the learner whose row to return.', + required=True, + type=str, + location=OpenApiParameter.PATH, +) +_VIEW_PARAMETER = OpenApiParameter( + name='view', + description="Send 'minimal' to reduce each row to the learner's username and overall percent.", + required=False, + type=str, + enum=['minimal'], + location=OpenApiParameter.QUERY, +) +_BAD_REQUEST = OpenApiResponse( + response=ErrorResponseSerializer, description='A query parameter is not valid.', +) +_UNAUTHENTICATED = OpenApiResponse( + response=ErrorResponseSerializer, description='The caller is not authenticated.', +) +_FORBIDDEN = OpenApiResponse( + response=ErrorResponseSerializer, + description=( + 'The caller may not manage the gradebook of this course (type authz), or the course ' + 'has the writable gradebook switched off (type grades/writable-gradebook-disabled).' + ), +) +_NOT_FOUND = OpenApiResponse( + response=ErrorResponseSerializer, + description='The course does not exist, or the learner is not enrolled in it.', +) + + +@extend_schema_view( + list=extend_schema( + operation_id='gradebook_entries_list', + tags=[TAG], + summary='List the gradebook of a course', + description=( + "Returns one row per active enrollment in the course, with the learner's overall " + 'percent and their score on each graded subsection. Callers who may manage the ' + 'gradebook of the course see every learner in it. A learner whose grade cannot be ' + 'computed is left out of the page, but count and num_pages still include them, so a ' + 'page may hold fewer rows than page_size.' + ), + parameters=[_COURSE_KEY_PATH_PARAMETER, _VIEW_PARAMETER], + responses={ + 200: GradebookEntrySerializer, + 400: _BAD_REQUEST, + 401: _UNAUTHENTICATED, + 403: _FORBIDDEN, + 404: _NOT_FOUND, + }, + ), + retrieve=extend_schema( + operation_id='gradebook_entries_retrieve', + tags=[TAG], + summary="Get a learner's gradebook row", + description="Returns one learner's row in the gradebook of one course. The enrollment need not be active.", + parameters=[_COURSE_KEY_PATH_PARAMETER, _USERNAME_PATH_PARAMETER, _VIEW_PARAMETER], + responses={ + 200: GradebookEntrySerializer, + 400: _BAD_REQUEST, + 401: _UNAUTHENTICATED, + 403: _FORBIDDEN, + 404: _NOT_FOUND, + }, + ), +) +class GradebookEntryViewSet( + StandardizedErrorMixin, + ScopedQuerysetMixin, + MinimalViewMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """ + The gradebook of a course: one row per learner enrolled in it. + + Access is checked before the course is looked up and before its writable + gradebook setting is read, so a caller without access to a course learns + neither whether it exists nor how it is configured. Every caller admitted + to a course sees every learner in it. + + The collection lists active enrollments only; the member address also + finds an inactive enrollment. + """ + + permission_classes = (IsAuthenticated, HasGradebookAccess, CourseExists, WritableGradebookEnabled) + serializer_class = GradebookEntrySerializer + pagination_class = GradePagination + filter_backends = (DjangoFilterBackend,) + filterset_class = GradebookEntryFilterSet + minimal_fields = ('username', 'percent') + scoping_policy = FullScopePolicy() + queryset = CourseEnrollment.objects.select_related('user', 'user__profile') + + def get_course_key(self): + """Return the course the request addresses.""" + return self.kwargs['course_key'] + + def get_queryset(self): + """Return the enrollments in the requested course, in a stable order.""" + queryset = super().get_queryset().filter(course_id=self.get_course_key()) + if self.action == 'list': + queryset = queryset.filter(is_active=True) + return use_read_replica_if_available(queryset).order_by('id') + + def get_object(self): + """Return the enrollment of the learner named in the URL.""" + enrollment = self.get_queryset().filter(user__username=self.kwargs['username']).first() + if enrollment is None: + raise NotFound() + self.check_object_permissions(self.request, enrollment) + return enrollment + + def list(self, request, *args, **kwargs): + """Return one page of gradebook rows.""" + self._validate_query() + page = self.paginate_queryset(self.filter_queryset(self.get_queryset())) + rows = gradebook_entry_rows(page, self.get_course_key()) + data = self.get_serializer(rows, many=True).data + return self.get_paginated_response(self.shape_minimal(data, request)) + + def retrieve(self, request, *args, **kwargs): + """Return one learner's gradebook row.""" + self._validate_query() + row = gradebook_entry_row(self.get_object(), self.get_course_key()) + return Response(self.shape_minimal(self.get_serializer(row).data, request)) + + def _validate_query(self): + """Refuse a representation this endpoint does not offer.""" + GradebookEntryQuerySerializer(data=self.request.query_params).is_valid(raise_exception=True) diff --git a/lms/djangoapps/grades/rest_api/v2/views/submission_histories.py b/lms/djangoapps/grades/rest_api/v2/views/submission_histories.py new file mode 100644 index 000000000000..439b5b2b8ce2 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/views/submission_histories.py @@ -0,0 +1,102 @@ +"""Submission history endpoint for the Grades API v2.""" + +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.scoping import FullScopePolicy, ScopedQuerysetMixin +from rest_framework import mixins, viewsets +from rest_framework.permissions import IsAdminUser + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit +from common.djangoapps.util.query import use_read_replica_if_available +from lms.djangoapps.grades.rest_api.v2.filters import SubmissionHistoryFilterSet +from lms.djangoapps.grades.rest_api.v2.pagination import GradePagination +from lms.djangoapps.grades.rest_api.v2.serializers import ( + SubmissionHistoryQuerySerializer, + SubmissionHistorySerializer, +) +from lms.djangoapps.grades.rest_api.v2.services import submission_history_rows +from openedx.core.djangoapps.enrollments.views import EnrollmentUserThrottle + +TAG = 'grade' +FULL_VIEW = 'full' + + +@can_disable_rate_limit +@extend_schema_view( + list=extend_schema( + operation_id='submission_histories_list', + tags=[TAG], + summary='List the submission histories of a course', + description=( + 'Returns one row per active enrollment in the course, listing the scored problems the ' + 'learner has submitted answers to and each recorded state of those answers. Global ' + 'staff only. Requests are rate limited.' + ), + parameters=[ + OpenApiParameter( + name='course_key', + description='Opaque key of the course.', + required=True, + type=str, + location=OpenApiParameter.PATH, + ), + OpenApiParameter( + name='view', + description="Send 'full' to include the definition of each problem.", + required=False, + type=str, + enum=[FULL_VIEW], + location=OpenApiParameter.QUERY, + ), + ], + responses={ + 200: SubmissionHistorySerializer, + 400: OpenApiResponse(response=ErrorResponseSerializer, description='A query parameter is not valid.'), + 401: OpenApiResponse(response=ErrorResponseSerializer, description='The caller is not authenticated.'), + 403: OpenApiResponse(response=ErrorResponseSerializer, description='The caller is not global staff.'), + 404: OpenApiResponse(response=ErrorResponseSerializer, description='The course does not exist.'), + 429: OpenApiResponse(response=ErrorResponseSerializer, description='Too many requests.'), + }, + ), +) +class SubmissionHistoryViewSet( + StandardizedErrorMixin, + ScopedQuerysetMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + """ + The submission history of every learner enrolled in one course. + + Only global staff may read it, and every row is visible to them. Requests + are throttled like the enrollment API, unless rate limiting is switched + off for the site. Problem definitions can be large, so they are returned + only on request. + """ + + permission_classes = (IsAdminUser,) + throttle_classes = (EnrollmentUserThrottle,) + serializer_class = SubmissionHistorySerializer + pagination_class = GradePagination + filter_backends = (DjangoFilterBackend,) + filterset_class = SubmissionHistoryFilterSet + scoping_policy = FullScopePolicy() + queryset = CourseEnrollment.objects.select_related('user') + + def get_queryset(self): + """Return the active enrollments in the requested course, in a stable order.""" + queryset = super().get_queryset().filter(course_id=self.kwargs['course_key'], is_active=True) + return use_read_replica_if_available(queryset).order_by('id') + + def list(self, request, *args, **kwargs): + """Return one page of submission histories.""" + query = SubmissionHistoryQuerySerializer(data=request.query_params) + query.is_valid(raise_exception=True) + page = self.paginate_queryset(self.filter_queryset(self.get_queryset())) + rows = submission_history_rows( + page, self.kwargs['course_key'], with_data=query.validated_data.get('view') == FULL_VIEW, + ) + return self.get_paginated_response(self.get_serializer(rows, many=True).data) diff --git a/lms/djangoapps/grades/rest_api/v2/views/subsection_grade_overrides.py b/lms/djangoapps/grades/rest_api/v2/views/subsection_grade_overrides.py new file mode 100644 index 000000000000..cf2fcee6d5ae --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/views/subsection_grade_overrides.py @@ -0,0 +1,160 @@ +"""Subsection grade override endpoint for the Grades API v2.""" + +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from rest_framework import viewsets +from rest_framework.exceptions import ValidationError +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from lms.djangoapps.grades.rest_api.v2.permissions import ( + CourseExists, + GradesNotFrozen, + HasGradebookAccess, + WritableGradebookEnabled, +) +from lms.djangoapps.grades.rest_api.v2.serializers import ( + SubsectionGradeOverrideListSerializer, + SubsectionGradeOverrideRequestSerializer, +) +from lms.djangoapps.grades.rest_api.v2.services import course_content, record_subsection_grade_overrides + +TAG = 'grade' + +_COURSE_KEY_PATH_PARAMETER = OpenApiParameter( + name='course_key', + description='Opaque key of the course whose grades to override.', + required=True, + type=str, + location=OpenApiParameter.PATH, +) +_REQUEST_EXAMPLE = OpenApiExample( + 'Two overrides', + request_only=True, + value={ + 'overrides': [ + { + 'username': 'learner_one', + 'usage_key': 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@homework_1', + 'earned_graded_override': 8.0, + 'possible_graded_override': 10.0, + 'comment': 'Regraded after appeal.', + }, + { + 'username': 'learner_two', + 'usage_key': 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@homework_1', + 'earned_graded_override': None, + }, + ], + }, +) +_INVALID_ITEMS_EXAMPLE = OpenApiExample( + 'Invalid items', + response_only=True, + status_codes=['400'], + value={ + 'type': 'https://docs.openedx.org/errors/validation', + 'title': 'Validation Error', + 'status': 400, + # The error handler renders the whole error mapping into detail; clients read errors. + 'detail': ( + "{'overrides[0].username': [ErrorDetail(string='No learner with this username is enrolled in " + "this course.', code='invalid')], 'overrides[1].usage_key': [ErrorDetail(string='No such " + "subsection in this course.', code='invalid')]}" + ), + 'instance': '/api/grade/v2/courses/course-v1:edX+DemoX+Demo_Course/subsection_grade_overrides/', + 'errors': { + 'overrides[0].username': ['No learner with this username is enrolled in this course.'], + 'overrides[1].usage_key': ['No such subsection in this course.'], + }, + }, +) + + +class SubsectionGradeOverrideViewSet(StandardizedErrorMixin, viewsets.ViewSet): + """ + Records overrides of learners' subsection grades in one course, as a batch. + + Every item is validated before any is stored, and the items are stored in + one transaction, so a batch with an invalid item stores nothing. The grades + the overrides change are recomputed once every item is stored. A failure + while recomputing is answered with a server error but leaves the overrides + stored: the error handler returns that error as a response rather than + raising it, so the request's own transaction still commits. + + Callers who may manage the gradebook of the course may record overrides + while its writable gradebook is switched on and its grades are not frozen. + + The write goes through the grade models and the grade recalculation task + rather than a model serializer, so this is a plain ViewSet. + """ + + permission_classes = ( + IsAuthenticated, HasGradebookAccess, CourseExists, WritableGradebookEnabled, GradesNotFrozen, + ) + serializer_class = SubsectionGradeOverrideListSerializer + # A batch nests a list of objects, which only a JSON body can carry. + parser_classes = (JSONParser,) + + def get_serializer(self, *args, **kwargs): + """Return the response serializer.""" + return self.serializer_class(*args, **kwargs) + + def get_course_key(self): + """Return the course the request addresses.""" + return self.kwargs['course_key'] + + @extend_schema( + operation_id='subsection_grade_overrides_create', + tags=[TAG], + summary='Override subsection grades', + description=( + 'A batch operation: records an override of one learner\'s grade on one subsection for ' + 'each item sent, then recomputes the grades the overrides change. The body must be ' + 'JSON. When any item is not valid nothing is recorded, and the errors are keyed by the ' + 'position of each failed item. The overrides are recorded before the grades are ' + 'recomputed: if recomputing fails, the response is a server error, yet every override ' + 'in the batch stays recorded and some of the grades it changes may not have been ' + 'recomputed. Sending the same batch again records the same values and recomputes them.' + ), + parameters=[_COURSE_KEY_PATH_PARAMETER], + request=SubsectionGradeOverrideRequestSerializer, + examples=[_REQUEST_EXAMPLE, _INVALID_ITEMS_EXAMPLE], + responses={ + 200: SubsectionGradeOverrideListSerializer, + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description='The body is not valid; nothing was recorded.', + ), + 401: OpenApiResponse( + response=ErrorResponseSerializer, description='The caller is not authenticated.', + ), + 403: OpenApiResponse( + response=ErrorResponseSerializer, + description=( + 'The caller may not manage the gradebook of this course (type authz), the course ' + 'has the writable gradebook switched off (type grades/writable-gradebook-disabled), ' + 'or its grades are frozen (type grades/grades-frozen).' + ), + ), + 404: OpenApiResponse( + response=ErrorResponseSerializer, description='The course does not exist.', + ), + 500: OpenApiResponse( + response=ErrorResponseSerializer, + description='Recomputing the grades failed after every override in the batch was recorded.', + ), + }, + ) + def create(self, request, course_key): + """Record a batch of subsection grade overrides.""" + course = course_content(course_key, depth=None) + batch = SubsectionGradeOverrideRequestSerializer( + data=request.data, context={'course_key': course_key, 'course': course}, + ) + if not batch.is_valid(): + raise ValidationError(batch.errors_by_path()) + rows = record_subsection_grade_overrides(request.user, course, batch.validated_data['overrides']) + return Response(self.get_serializer({'overrides': rows}).data) diff --git a/lms/djangoapps/grades/rest_api/v2/views/subsection_grades.py b/lms/djangoapps/grades/rest_api/v2/views/subsection_grades.py new file mode 100644 index 000000000000..ffc95e44b408 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/views/subsection_grades.py @@ -0,0 +1,156 @@ +"""Subsection grade endpoints for the Grades API v2.""" + +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.scoping import FullScopePolicy, ScopedQuerysetMixin +from rest_framework import mixins, viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from lms.djangoapps.grades.models import PersistentSubsectionGradeOverride +from lms.djangoapps.grades.rest_api.v2.filters import SubsectionGradeOverrideHistoryFilterSet +from lms.djangoapps.grades.rest_api.v2.pagination import GradePagination +from lms.djangoapps.grades.rest_api.v2.permissions import HasGradebookAccess +from lms.djangoapps.grades.rest_api.v2.serializers import ( + SubsectionGradeOverrideHistoryRecordSerializer, + SubsectionGradeSerializer, +) +from lms.djangoapps.grades.rest_api.v2.services import enrolled_learner, read_subsection_grade + +TAG = 'grade' + +_PATH_PARAMETERS = [ + OpenApiParameter( + name='username', + description='Username of a learner enrolled in the course of the subsection.', + required=True, + type=str, + location=OpenApiParameter.PATH, + ), + OpenApiParameter( + name='usage_key', + description='Opaque key of the subsection.', + required=True, + type=str, + location=OpenApiParameter.PATH, + ), +] +_UNAUTHENTICATED = OpenApiResponse( + response=ErrorResponseSerializer, description='The caller is not authenticated.', +) +_FORBIDDEN = OpenApiResponse( + response=ErrorResponseSerializer, + description='The caller may not manage the gradebook of the course the subsection belongs to.', +) + + +class _SubsectionGradeAddress: + """Reads the course and learner a subsection grade address names.""" + + def get_course_key(self): + """Return the course of the subsection the request addresses.""" + return self.kwargs['usage_key'].course_key + + def get_learner(self): + """Return the learner the request addresses, who must be enrolled in the course.""" + return enrolled_learner(self.kwargs['username'], self.get_course_key()) + + +class SubsectionGradeViewSet(StandardizedErrorMixin, _SubsectionGradeAddress, viewsets.ViewSet): + """ + One learner's grade on one subsection. + + Callers who may manage the gradebook of the subsection's course may read the + grade of any learner enrolled in it. The grade is read from the grade models + rather than from one queryset, so this is a plain ViewSet. + """ + + permission_classes = (IsAuthenticated, HasGradebookAccess) + serializer_class = SubsectionGradeSerializer + + def get_serializer(self, *args, **kwargs): + """Return the response serializer.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + operation_id='subsection_grades_retrieve', + tags=[TAG], + summary="Get a learner's subsection grade", + description=( + "Returns the points a learner has earned on a subsection and the override recorded " + "on them, if any. The history of the override is at override_history_records/." + ), + parameters=_PATH_PARAMETERS, + responses={ + 200: SubsectionGradeSerializer, + 401: _UNAUTHENTICATED, + 403: _FORBIDDEN, + 404: OpenApiResponse( + response=ErrorResponseSerializer, + description=( + 'The learner is not enrolled in the course, or the course has no such subsection ' + '(type not-found), or the subsection is not available to the learner ' + '(type grades/subsection-unavailable).' + ), + ), + }, + ) + def retrieve(self, request, username, usage_key): # pylint: disable=unused-argument + """Return one learner's grade on one subsection.""" + grade = read_subsection_grade(self.get_learner(), usage_key) + return Response(self.get_serializer(grade).data) + + +@extend_schema_view( + list=extend_schema( + operation_id='subsection_grade_override_history_records_list', + tags=[TAG], + summary="List the override history of a learner's subsection grade", + description=( + 'Returns every change made to the override recorded on the grade, newest first. ' + 'The page is empty when no grade has been stored for the learner.' + ), + parameters=_PATH_PARAMETERS, + responses={ + 200: SubsectionGradeOverrideHistoryRecordSerializer, + 400: OpenApiResponse(response=ErrorResponseSerializer, description='A query parameter is not valid.'), + 401: _UNAUTHENTICATED, + 403: _FORBIDDEN, + 404: OpenApiResponse( + response=ErrorResponseSerializer, description='The learner is not enrolled in the course.', + ), + }, + ), +) +class SubsectionGradeOverrideHistoryViewSet( + StandardizedErrorMixin, + _SubsectionGradeAddress, + ScopedQuerysetMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + """ + The changes made to the override recorded on one learner's grade on one subsection. + + Access is the same as for the grade itself, and every caller admitted sees + every change. + """ + + permission_classes = (IsAuthenticated, HasGradebookAccess) + serializer_class = SubsectionGradeOverrideHistoryRecordSerializer + pagination_class = GradePagination + filter_backends = (DjangoFilterBackend,) + filterset_class = SubsectionGradeOverrideHistoryFilterSet + scoping_policy = FullScopePolicy() + queryset = PersistentSubsectionGradeOverride.history.select_related('history_user') + + def get_queryset(self): + """Return the history of the addressed grade, newest first.""" + usage_key = self.kwargs['usage_key'] + return super().get_queryset().filter( + grade__user_id=self.get_learner().id, + grade__course_id=usage_key.course_key, + grade__usage_key=usage_key, + ).order_by('-history_date', '-history_id') diff --git a/lms/djangoapps/grades/rest_api/v2/views/unmatched.py b/lms/djangoapps/grades/rest_api/v2/views/unmatched.py new file mode 100644 index 000000000000..8ab136674c60 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/v2/views/unmatched.py @@ -0,0 +1,28 @@ +"""Catch-all view for addresses under the Grades API v2 collections that match no route.""" + +from drf_spectacular.utils import extend_schema +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from rest_framework.exceptions import NotFound +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + + +@extend_schema(exclude=True) +class UnmatchedRouteView(StandardizedErrorMixin, APIView): + """ + Answers every request with a JSON not-found error. + + The key converters reject a malformed or retired course or usage key by + not matching the route at all, which would otherwise reach the site's + HTML 404 page. This view is mounted after the real routes of each + collection so those addresses get the same error body as the rest of the + API. It refuses before authentication and permission checks run, so the + answer is the same whoever asks and whatever the method. + """ + + permission_classes = (AllowAny,) + + def initial(self, request, *args, **kwargs): + """Refuse the request before any handler runs.""" + self.format_kwarg = self.get_format_suffix(**kwargs) # pylint: disable=attribute-defined-outside-init + raise NotFound() diff --git a/lms/urls.py b/lms/urls.py index 35a1b6cdabcd..4cb721d59be1 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -123,6 +123,9 @@ path('api/enrollment/v1/', include('openedx.core.djangoapps.enrollments.urls')), path('api/enrollment/v2/', include('openedx.core.djangoapps.enrollments.v2.urls')), + # Grades API v2. Grades v1 stays mounted by the grades plugin at api/grades/v1/. + path('api/grade/v2/', include('lms.djangoapps.grades.rest_api.v2.urls')), + # Agreements API RESTful endpoints path('api/agreements/v1/', include('openedx.core.djangoapps.agreements.urls')), From 2ba58753826638294353c52041bf7db79ff4abb7 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Thu, 1 Oct 2026 00:25:55 +0500 Subject: [PATCH 3/6] docs: mark Grades API v1 deprecated in favour of v2 Docstring-only markers on the nine v1 views, naming each successor. No statement, decorator or import changes; the syntax trees match the base with docstrings blanked. --- .../grades/rest_api/v1/gradebook_views.py | 34 +++++++++++++++++++ lms/djangoapps/grades/rest_api/v1/views.py | 33 ++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py index fe69d38b3527..162acce15e87 100644 --- a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py @@ -317,6 +317,14 @@ class CourseGradingView(BaseCourseView): * short_label - A short label for graded assignments (e.g. 'HW 01'). * assignment_type - The assignment type of this subsection (for graded assignments only). + + **Deprecated** + + Superseded by: + + * ``GET /api/grade/v2/courses/{course_key}/grading_policy/`` + + This version is deprecated and will be removed; see https://github.com/openedx/openedx-platform/issues/DEPR_TBD """ @course_author_access_required def get(self, request, course_key): @@ -482,6 +490,15 @@ class GradebookView(GradeViewMixin, PaginatedAPIView): Note: It's important that `GradeViewMixin` is the first inherited class here, so that self.api_error returns error responses as expected. + + **Deprecated** + + Superseded by: + + * ``GET /api/grade/v2/courses/{course_key}/gradebook_entries/`` + * ``GET /api/grade/v2/courses/{course_key}/gradebook_entries/{username}/`` + + This version is deprecated and will be removed; see https://github.com/openedx/openedx-platform/issues/DEPR_TBD """ pagination_class = CourseEnrollmentPagination @@ -860,6 +877,14 @@ class GradebookBulkUpdateView(GradeViewMixin, PaginatedAPIView): "reason": "User matching query does not exist." } ] + + **Deprecated** + + Superseded by: + + * ``POST /api/grade/v2/courses/{course_key}/subsection_grade_overrides/`` + + This version is deprecated and will be removed; see https://github.com/openedx/openedx-platform/issues/DEPR_TBD """ @verify_course_exists("Requested grade for unknown course {course}") @@ -1092,6 +1117,15 @@ class SubsectionGradeView(GradeViewMixin, APIView): } ] } + + **Deprecated** + + Superseded by: + + * ``GET /api/grade/v2/subsection_grades/{username},{usage_key}/`` + * ``GET /api/grade/v2/subsection_grades/{username},{usage_key}/override_history_records/`` + + This version is deprecated and will be removed; see https://github.com/openedx/openedx-platform/issues/DEPR_TBD """ def get(self, request, subsection_id): diff --git a/lms/djangoapps/grades/rest_api/v1/views.py b/lms/djangoapps/grades/rest_api/v1/views.py index d49d66c90661..03c1598dd498 100644 --- a/lms/djangoapps/grades/rest_api/v1/views.py +++ b/lms/djangoapps/grades/rest_api/v1/views.py @@ -102,6 +102,15 @@ class CourseGradesView(GradeViewMixin, PaginatedAPIView): "percent": 0.19, "letter_grade": null, }] + + **Deprecated** + + Superseded by: + + * ``GET /api/grade/v2/course_grades/?course_key={course_key}`` + * ``GET /api/grade/v2/course_grades/{username},{course_key}/`` + + This version is deprecated and will be removed; see https://github.com/openedx/openedx-platform/issues/DEPR_TBD """ authentication_classes = ( JwtAuthentication, @@ -180,6 +189,14 @@ class CourseGradingPolicy(GradeViewMixin, ListAPIView): * weight: The weight, or effect, of the assignment type on the learner's final grade. + + **Deprecated** + + Superseded by: + + * ``GET /api/grade/v2/courses/{course_key}/grading_policy/?view=minimal`` + + This version is deprecated and will be removed; see https://github.com/openedx/openedx-platform/issues/DEPR_TBD """ allow_empty = False @@ -226,6 +243,14 @@ def get(self, request, course_id, *args, **kwargs): # pylint: disable=arguments 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. + + **Deprecated** + + Superseded by: + + * ``GET /api/grade/v2/course_grades/?view=full`` + + This version is deprecated and will be removed; see https://github.com/openedx/openedx-platform/issues/DEPR_TBD """ authentication_classes = ( JwtAuthentication, @@ -365,6 +390,14 @@ def _serialize_section_grades(user, course_key, course_grade): class SubmissionHistoryView(GradeViewMixin, PaginatedAPIView): """ Submission history corresponding to ProblemBlocks present in the course. + + **Deprecated** + + Superseded by: + + * ``GET /api/grade/v2/courses/{course_key}/submission_histories/`` + + This version is deprecated and will be removed; see https://github.com/openedx/openedx-platform/issues/DEPR_TBD """ authentication_classes = ( JwtAuthentication, From 37951909407f442da93f61bb79d3a2b418b010f0 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 7 Oct 2026 12:52:51 +0500 Subject: [PATCH 4/6] style: document the cohort-assignment test helpers pylint's missing-function-docstring check flagged three helpers in the v2 grade tests. --- lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py | 1 + .../grades/rest_api/v2/tests/test_gradebook_entries.py | 1 + .../grades/rest_api/v2/tests/test_subsection_grades.py | 1 + 3 files changed, 3 insertions(+) diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py b/lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py index 36a23f6d14a7..b09d097465c2 100644 --- a/lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_course_grades.py @@ -825,6 +825,7 @@ def setUp(self): self.client.force_authenticate(self.global_staff) def assert_assigned_once(self, url): + """Assert the first read of ``url`` assigns the uncohorted learner, and a second read writes nothing.""" with capture_cohort_assignment() as first: assert self.client.get(url).status_code == status.HTTP_200_OK assert first.tables == COHORT_ASSIGNMENT_TABLES diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py b/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py index 55c0d4a58a51..eee31056cdf2 100644 --- a/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py @@ -670,6 +670,7 @@ def setUp(self): self.client.force_authenticate(self.global_staff) def assert_not_assigned(self, url): + """Assert reading ``url`` leaves the uncohorted learner without a cohort, and return the response.""" with capture_cohort_assignment() as record: response = self.client.get(url) assert response.status_code == status.HTTP_200_OK diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grades.py b/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grades.py index 03b1801e1582..b43fecae6f47 100644 --- a/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grades.py +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_subsection_grades.py @@ -495,6 +495,7 @@ def capture(self, url): return record def assert_assigned_once(self, url): + """Assert the first read of ``url`` assigns the uncohorted learner, and a second read writes nothing.""" first = self.capture(url) assert first.tables == COHORT_ASSIGNMENT_TABLES assert first.memberships == [('uncohorted', AUTO_COHORT)] From 5b83e8d036c70293c543178b08ec99c7369a6625 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 21 Oct 2026 16:27:43 +0500 Subject: [PATCH 5/6] fix: give the Grades v1 course grade detail a distinct operationId GET /api/grades/v1/courses/ and GET /api/grades/v1/courses/{course_id}/ tokenize to the same operationId, so drf-spectacular published the second as api_grades_v1_courses_retrieve_2, depending on registration order. The detail address is now suffixed _legacy, as the colliding Enrollment v2 address already is. Co-Authored-By: Claude Opus 5.5 --- lms/envs/common.py | 2 +- lms/lib/spectacular.py | 25 +++++++++++++++---------- lms/lib/tests/test_spectacular.py | 26 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 1fc662d26e15..c93712a702e0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2182,7 +2182,7 @@ # SERVERS is environment-specific (LMS_ROOT_URL differs per env) and is # set in devstack.py / production.py. } -# The colliding legacy Enrollment v2 address gets a distinct operationId (lms/lib/spectacular.py). +# Colliding legacy addresses get a distinct operationId (lms/lib/spectacular.py). REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS'] = 'lms.lib.spectacular.LmsAutoSchema' # noqa: F405 diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 012e61ea351d..75a7f30452c4 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -6,10 +6,15 @@ LEGACY_V2_PREFIX = "/api/enrollment/v2/" -# The one legacy address that differs from its conforming twin only by the -# trailing slash, so both tokenize to the same operationId. The other legacy -# addresses already have distinct ids and keep them. -COLLIDING_LEGACY_PATH = "/api/enrollment/v2/enrollments" +# Legacy addresses that tokenize to the same operationId as another address, +# so drf-spectacular would break the tie with a numeral suffix in registration +# order. The other legacy addresses already have distinct ids and keep them. +COLLIDING_LEGACY_PATHS = ( + # Differs from its conforming twin only by the trailing slash. + "/api/enrollment/v2/enrollments", + # Tokenizes like the Grades v1 course grade list, /api/grades/v1/courses/. + "/api/grades/v1/courses/{course_id}/", +) # Mounts published in the LMS schema. Anything else is left out. SCHEMA_PATH_PATTERNS = ( @@ -47,18 +52,18 @@ def lms_api_filter(endpoints): class LmsAutoSchema(AutoSchema): """ - Give the colliding legacy Enrollment v2 address a distinct operationId. + Give each colliding legacy address a distinct operationId. - It and its conforming twin tokenize to the same operationId, so + Each tokenizes to the same operationId as another address, so drf-spectacular would otherwise break the tie with a numeral suffix in - registration order. The conforming address keeps the clean id; the - deprecated one is suffixed ``_legacy``. + registration order. The other address keeps the clean id; the colliding + legacy one is suffixed ``_legacy``. """ def get_operation_id(self): - """Suffix the colliding legacy address so the pair never shares an operationId.""" + """Suffix a colliding legacy address so the pair never shares an operationId.""" operation_id = super().get_operation_id() - if self.path == COLLIDING_LEGACY_PATH: + if self.path in COLLIDING_LEGACY_PATHS: return f"{operation_id}_legacy" return operation_id diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index a73df527b27a..6ab04ee77cfd 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -7,8 +7,10 @@ from drf_spectacular.generators import SchemaGenerator from drf_spectacular.settings import spectacular_settings from rest_framework import serializers, viewsets +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.settings import api_settings +from rest_framework.views import APIView from lms.lib.spectacular import LmsAutoSchema, lms_api_filter, lms_mark_legacy_paths_deprecated @@ -103,6 +105,16 @@ def retrieve(self, request, pk=None): return Response({}) +class _GradesV1View(APIView): + """Stand-in for a Grades v1 view: a plain APIView, so every GET is a ``retrieve``.""" + + permission_classes = (AllowAny,) + schema = LmsAutoSchema() + + def get(self, request, course_id=None): + return Response({}) + + class LmsAutoSchemaTest(SimpleTestCase): """Dual mounts get deterministic operationIds instead of registration-order numeral suffixes.""" @@ -125,3 +137,17 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): assert ids["/api/enrollment/v2/courses/{id}/"] == "v2_courses_retrieve" # Already distinct from every conforming id, so it keeps its name. assert ids["/api/enrollment/v2/enrollment/{id}"] == "v2_enrollment_retrieve" + + def test_the_grades_v1_course_grade_detail_is_suffixed(self): + """The course grade list and detail would otherwise both be ``api_grades_v1_courses_retrieve``.""" + patterns = [ + path("api/grades/v1/courses/", _GradesV1View.as_view()), + path("api/grades/v1/courses//", _GradesV1View.as_view()), + ] + with mock.patch.object(spectacular_settings, "SCHEMA_PATH_PREFIX", "/api/enrollment"): + schema = SchemaGenerator(patterns=patterns).get_schema(request=None, public=True) + ids = {p: op["operationId"] for p, item in schema["paths"].items() for op in item.values()} + assert ids == { + "/api/grades/v1/courses/": "api_grades_v1_courses_retrieve", + "/api/grades/v1/courses/{course_id}/": "api_grades_v1_courses_retrieve_legacy", + } From 16a7bbbf8dba8945dbbcdf61ab5aaac1a0dd6d74 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 21 Oct 2026 16:27:43 +0500 Subject: [PATCH 6/6] refactor: share the CCX gradebook check and problem listing across Grades versions Grades v2 imported _has_ccx_gradebook_access from the v1 gradebook views and called SubmissionHistoryView.get_problem_blocks, so removing v1 would have meant editing v2. Both now live in lms/djangoapps/grades/rest_api/helpers.py, which v2 imports. v1 keeps its own copies, unchanged, because it is frozen until it is removed. The tests that compare v1 with v2 on a CCX course patch both lookups of the master course. Co-Authored-By: Claude Opus 5.5 --- lms/djangoapps/grades/rest_api/helpers.py | 63 +++++++++++++++++++ .../grades/rest_api/v2/permissions.py | 4 +- lms/djangoapps/grades/rest_api/v2/services.py | 4 +- .../v2/tests/test_course_grading_policy.py | 5 +- .../v2/tests/test_gradebook_entries.py | 2 +- .../rest_api/v2/tests/test_infrastructure.py | 4 ++ 6 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 lms/djangoapps/grades/rest_api/helpers.py diff --git a/lms/djangoapps/grades/rest_api/helpers.py b/lms/djangoapps/grades/rest_api/helpers.py new file mode 100644 index 000000000000..485efe70ea59 --- /dev/null +++ b/lms/djangoapps/grades/rest_api/helpers.py @@ -0,0 +1,63 @@ +""" +Helpers shared by the versions of the Grades REST API. + +Grades v1 keeps its own copies of these, so that the deprecated version stays +unchanged until it is removed; removing it then leaves the newer versions +untouched. +""" + +from django.conf import settings +from django.http import Http404 + +from common.djangoapps.student.roles import CourseCcxCoachRole, CourseInstructorRole, CourseStaffRole +from lms.djangoapps.ccx.permissions import VIEW_CCX_COACH_DASHBOARD +from openedx.core.lib.courses import get_course_by_id + + +def has_ccx_gradebook_access(user, course_key): + """ + Return True if ``user`` may use the gradebook of the CCX course ``course_key``. + + Applies the same platform-level and course-level guards as + ``lms.djangoapps.ccx.api.v2.permissions.IsCCXCoach``: + + * ``CUSTOM_COURSES_EDX`` must be enabled. + * The master course must have ``enable_ccx = True``. + * The user must either have the staff permission on the master course + (``VIEW_CCX_COACH_DASHBOARD``), hold the CCX coach role on it, or be site + staff or hold a staff or instructor role on the CCX itself (which + ``create_ccx_course`` grants to the coach). + """ + if not settings.CUSTOM_COURSES_EDX: + return False + master_course_key = course_key.to_course_locator() + try: + master_course = get_course_by_id(master_course_key) + except Http404: + return False + if not master_course.enable_ccx: + return False + return ( + user.is_staff + or user.has_perm(VIEW_CCX_COACH_DASHBOARD, master_course) + or CourseCcxCoachRole(master_course_key).has_user(user) + or CourseStaffRole(course_key).has_user(user) + or CourseInstructorRole(course_key).has_user(user) + ) + + +def get_problem_blocks(course): + """ + Return the scored problem blocks of ``course``, in course order. + + Only problems placed directly in a unit are found: problems inside legacy + library content blocks or content libraries are not included. + """ + 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 diff --git a/lms/djangoapps/grades/rest_api/v2/permissions.py b/lms/djangoapps/grades/rest_api/v2/permissions.py index f58d797ec94f..f25e0ad3f60c 100644 --- a/lms/djangoapps/grades/rest_api/v2/permissions.py +++ b/lms/djangoapps/grades/rest_api/v2/permissions.py @@ -12,7 +12,7 @@ from lms.djangoapps.courseware.access import has_access from lms.djangoapps.grades.api import is_writable_gradebook_enabled from lms.djangoapps.grades.grade_utils import are_grades_frozen -from lms.djangoapps.grades.rest_api.v1.gradebook_views import _has_ccx_gradebook_access +from lms.djangoapps.grades.rest_api.helpers import has_ccx_gradebook_access from lms.djangoapps.grades.rest_api.v2.errors import GradesFrozen, WritableGradebookDisabled from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -66,7 +66,7 @@ def has_permission(self, request, view): """Return True when the caller may manage the gradebook of the requested course.""" course_key = view.get_course_key() if is_ccx_course(course_key): - return _has_ccx_gradebook_access(request.user, course_key) + return has_ccx_gradebook_access(request.user, course_key) return has_course_author_access(request.user, course_key) diff --git a/lms/djangoapps/grades/rest_api/v2/services.py b/lms/djangoapps/grades/rest_api/v2/services.py index 3de467f73317..97acd0000501 100644 --- a/lms/djangoapps/grades/rest_api/v2/services.py +++ b/lms/djangoapps/grades/rest_api/v2/services.py @@ -41,7 +41,7 @@ from lms.djangoapps.grades.config.waffle import ENFORCE_FREEZE_GRADE_AFTER_COURSE_END from lms.djangoapps.grades.course_data import CourseData from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride -from lms.djangoapps.grades.rest_api.v1.views import SubmissionHistoryView +from lms.djangoapps.grades.rest_api.helpers import get_problem_blocks from lms.djangoapps.grades.rest_api.v2.errors import SubsectionUnavailable from lms.djangoapps.grades.subsection_grade import CreateSubsectionGrade from lms.djangoapps.grades.subsection_grade_factory import SubsectionGradeFactory @@ -531,7 +531,7 @@ def submission_history_rows(enrollments, course_key, with_data=False): course = get_course(course_key, depth=4) except CourseRunNotFound as exc: raise NotFound() from exc - problems = SubmissionHistoryView.get_problem_blocks(course) + problems = get_problem_blocks(course) submissions = _submissions_by_learner_and_problem(enrollments, course_key, problems) rows = [] for enrollment in enrollments: diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_course_grading_policy.py b/lms/djangoapps/grades/rest_api/v2/tests/test_course_grading_policy.py index 25b5ae340eb1..5295dc8b9cd6 100644 --- a/lms/djangoapps/grades/rest_api/v2/tests/test_course_grading_policy.py +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_course_grading_policy.py @@ -303,7 +303,10 @@ def test_master_course_that_disables_ccx_leaves_its_staff_the_minimal_view_only( staff = UserFactory() OrgStaffRole(self.course.id.org).add_users(staff) self.client.force_authenticate(staff) - with patch('lms.djangoapps.grades.rest_api.v1.gradebook_views.get_course_by_id', return_value=master_course): + # v1 keeps its own copy of the CCX check, so both lookups are patched. + with patch( + 'lms.djangoapps.grades.rest_api.v1.gradebook_views.get_course_by_id', return_value=master_course, + ), patch('lms.djangoapps.grades.rest_api.helpers.get_course_by_id', return_value=master_course): statuses = reads_of_each_policy_address(self.client, self.ccx_key) assert statuses == (200, 403, 403, 200) diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py b/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py index eee31056cdf2..9efc1150988d 100644 --- a/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_gradebook_entries.py @@ -271,7 +271,7 @@ def test_coach_is_refused_when_the_master_course_disables_ccx(self): master_course.enable_ccx = False self.client.force_authenticate(self.coach) with patch( - 'lms.djangoapps.grades.rest_api.v1.gradebook_views.get_course_by_id', return_value=master_course, + 'lms.djangoapps.grades.rest_api.helpers.get_course_by_id', return_value=master_course, ): response = self.client.get(gradebook_entry_list_url(self.ccx_key)) assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug='authz') diff --git a/lms/djangoapps/grades/rest_api/v2/tests/test_infrastructure.py b/lms/djangoapps/grades/rest_api/v2/tests/test_infrastructure.py index f84855f32414..9425060b0852 100644 --- a/lms/djangoapps/grades/rest_api/v2/tests/test_infrastructure.py +++ b/lms/djangoapps/grades/rest_api/v2/tests/test_infrastructure.py @@ -193,9 +193,13 @@ def test_nobody_is_admitted_when_custom_courses_are_off(self, kind): def test_nobody_is_admitted_when_the_master_course_disables_ccx(self): master_course = self.store.get_course(self.course.id) master_course.enable_ccx = False + # v1 keeps its own copy of the CCX check, so both lookups are patched. with patch( 'lms.djangoapps.grades.rest_api.v1.gradebook_views.get_course_by_id', return_value=master_course, + ), patch( + 'lms.djangoapps.grades.rest_api.helpers.get_course_by_id', + return_value=master_course, ): assert self._v2_allows(self.coach, self.ccx_key) is False assert self._legacy_allows(self.coach, self.ccx_key) is False