diff --git a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py index 7793d9571d..1fbfb6070d 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py @@ -2,20 +2,23 @@ Tests for v1 views """ - from collections import OrderedDict from unittest.mock import MagicMock, patch +from urllib.parse import urlencode import ddt +from django.db import connections from django.urls import reverse from opaque_keys import InvalidKeyError from rest_framework import status from rest_framework.test import APITestCase -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.tests.factories import GlobalStaffFactory, UserFactory +from lms.djangoapps.courseware.tests.test_submitting_problems import TestSubmittingProblems from lms.djangoapps.grades.rest_api.v1.tests.mixins import GradeViewTestMixin from lms.djangoapps.grades.rest_api.v1.views import CourseGradesView from openedx.core.djangoapps.user_authn.tests.utils import AuthAndScopesTestMixin +from xmodule.modulestore.tests.factories import BlockFactory @ddt.ddt @@ -257,3 +260,355 @@ class CourseGradesViewTest(GradeViewTestMixin, APITestCase): ]) assert expected_data == resp.data + + +class SectionGradesBreakdownTest(GradeViewTestMixin, APITestCase): + """ + Tests for course grading status for all users in a course + e.g. /api/grades/v1/section_grades_breakdown + /api/grades/v1/section_grades_breakdown/?course_id={course_id} + /api/grades/v1/section_grades_breakdown/?course_id={course_id}&username={username} + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.namespaced_url = 'grades_api:v1:section_grades_breakdown' + cls.section_breakdown = ( + [ + { + 'category': 'Homework', + 'detail': f'Homework {i} Unreleased - 0% (?/?)', + 'label': f'HW {i:02d}', 'percent': .0 + } + for i in range(1, 11) + ] + + [ + { + 'category': 'Homework', + 'detail': 'Homework 11 Unreleased - 0% (?/?)', + 'label': 'HW 11', + 'mark': {'detail': 'The lowest 2 Homework scores are dropped.'}, + 'percent': 0.0 + }, + { + 'category': 'Homework', + 'detail': 'Homework 12 Unreleased - 0% (?/?)', + 'label': 'HW 12', + 'mark': {'detail': 'The lowest 2 Homework scores are dropped.'}, + 'percent': 0.0 + } + ] + + [ + { + 'category': 'Homework', + 'detail': 'Homework Average = 0%', + 'label': 'HW Avg', 'percent': 0.0, + 'prominent': True + } + ] + + [ + { + 'category': 'Lab', + 'detail': f'Lab {i} Unreleased - 0% (?/?)', + 'label': f'Lab {i:02d}', 'percent': .0 + } + for i in range(1, 11) + ] + + [ + { + 'category': 'Lab', + 'detail': 'Lab 11 Unreleased - 0% (?/?)', + 'label': 'Lab 11', + 'mark': {'detail': 'The lowest 2 Lab scores are dropped.'}, + 'percent': 0.0 + }, + { + 'category': 'Lab', + 'detail': 'Lab 12 Unreleased - 0% (?/?)', + 'label': 'Lab 12', + 'mark': {'detail': 'The lowest 2 Lab scores are dropped.'}, + 'percent': 0.0 + }, + { + 'category': 'Lab', + 'detail': 'Lab Average = 0%', + 'label': 'Lab Avg', + 'percent': 0.0, + 'prominent': True + }, + { + 'category': 'Midterm Exam', + 'detail': 'Midterm Exam = 0%', + 'label': 'Midterm', + 'percent': 0.0, + 'prominent': True + }, + { + 'category': 'Final Exam', + 'detail': 'Final Exam = 0%', + 'label': 'Final', + 'percent': 0.0, + 'prominent': True + } + ] + ) + + def get_url(self, query_params=None): + """ + Helper function to create the url + """ + base_url = reverse( + self.namespaced_url, + ) + if query_params: + base_url = f'{base_url}?{query_params}' + return base_url + + def test_anonymous(self): + resp = self.client.get(self.get_url()) + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_student(self): + self.client.login(username=self.student.username, password=self.password) + resp = self.client.get(self.get_url()) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + def test_course_does_not_exist(self): + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get( + self.get_url( + urlencode({'course_id': 'course-v1:MITx+8.MechCX+2014_T1'}) + ) + ) + expected_data = OrderedDict( + [ + ('next', None), + ('previous', None), + ('results', []) + ] + ) + assert resp.status_code == status.HTTP_200_OK + assert expected_data == resp.data + + def test_course_no_enrollments(self): + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get( + self.get_url(urlencode({'course_id': self.empty_course.id})) + ) + assert resp.status_code == status.HTTP_200_OK + expected_data = OrderedDict( + [ + ('next', None), + ('previous', None), + ('results', []), + ] + ) + assert expected_data == resp.data + + def test_staff_can_get_all_grades(self): + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get(self.get_url(urlencode({'course_id': self.course_key}))) + + # This should have permission to access this API endpoint. + assert resp.status_code == status.HTTP_200_OK + expected_data = OrderedDict( + [ + ('next', None), + ('previous', None), + ( + 'results', + [ + { + 'course_id': str(self.course_key), + 'current_grade': 0, + 'passed': False, + 'section_breakdown': self.section_breakdown, + 'username': 'student' + }, + { + 'course_id': str(self.course_key), + 'current_grade': 0, + 'passed': False, + 'section_breakdown': self.section_breakdown, + 'username': 'other_student' + }, + { + 'course_id': str(self.course_key), + 'current_grade': 0, + 'passed': False, + 'section_breakdown': self.section_breakdown, + 'username': 'program_student' + }, + { + 'course_id': str(self.course_key), + 'current_grade': 0, + 'passed': False, + 'section_breakdown': self.section_breakdown, + 'username': 'program_masters_student' + } + ] + ) + ] + ) + assert expected_data == resp.data + + def test_staff_can_get_all_grades_for_user(self): + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get(self.get_url(urlencode({'course_id': self.course_key, + 'username': 'student'}))) + + # this should have permission to access this API endpoint + assert resp.status_code == status.HTTP_200_OK + expected_data = OrderedDict( + [ + ('next', None), + ('previous', None), + ( + 'results', + [ + { + 'course_id': str(self.course_key), + 'current_grade': 0, + 'passed': False, + 'section_breakdown': self.section_breakdown, + 'username': 'student' + } + ] + ) + ] + ) + assert expected_data == resp.data + + +class CourseSubmissionHistoryTest(GradeViewTestMixin, APITestCase): + """ + Tests for course submission history for all users in a course + e.g. /api/grades/v1/submission_history/{course_id} + /api/grades/v1/submission_history/{course_id}/?username={username} + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.namespaced_url = 'grades_api:v1:submission_history' + + def get_url(self, course_key, query_params=None): + """ + Helper function to create the url + """ + base_url = reverse( + self.namespaced_url, + kwargs={ + 'course_id': course_key, + } + ) + if query_params: + base_url = f'{base_url}?{query_params}' + return base_url + + def test_anonymous(self): + resp = self.client.get(self.get_url(course_key=self.course_key)) + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_student(self): + self.client.login(username=self.student.username, password=self.password) + resp = self.client.get(self.get_url(course_key=self.course_key)) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + def test_course_does_not_exist(self): + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get( + self.get_url(course_key='course-v1:MITx+8.MechCX+2014_T1') + ) + expected_data = OrderedDict([('next', None), ('previous', None), ('results', [])]) + assert resp.status_code == status.HTTP_200_OK + assert expected_data == resp.data + + def test_course_no_enrollments(self): + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get( + self.get_url(course_key=self.empty_course.id) + ) + assert resp.status_code == status.HTTP_200_OK + expected_data = OrderedDict([('next', None), ('previous', None), ('results', [])]) + assert expected_data == resp.data + + +class CourseSubmissionHistoryWithDataTest(TestSubmittingProblems): + """ + Tests for course submission history for all users in a course + e.g. /api/grades/v1/submission_history/?course_id={course_id} + /api/grades/v1/submission_history?course_id={course_id}&username={username} + """ + + # Tell Django to clean out all databases, not just default + databases = set(connections) + + def setUp(self): + super().setUp() + self.namespaced_url = 'grades_api:v1:submission_history' + self.password = 'test' + self.basic_setup() + self.global_staff = GlobalStaffFactory.create() + + def basic_setup(self, late=False, reset=False, showanswer=False): + """ + Set up a simple course for testing basic grading functionality. + """ + grading_policy = { + "GRADER": [{ + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "HW", + "weight": 1.0 + }], + "GRADE_CUTOFFS": { + 'A': .9, + 'B': .33 + } + } + self.add_grading_policy(grading_policy) + + # set up a simple course with four problems + homework = self.add_graded_section_to_course('homework', late=late, reset=reset, showanswer=showanswer) + vertical = BlockFactory.create( + parent_location=homework.location, + category='vertical', + display_name='Subsection 1', + ) + self.add_dropdown_to_section(vertical.location, 'p1', 1) + self.add_dropdown_to_section(vertical.location, 'p2', 1) + self.add_dropdown_to_section(vertical.location, 'p3', 1) + + self.refresh_course() + + def get_url(self, course_key, query_params=None): + """ + Helper function to create the url + """ + base_url = reverse( + self.namespaced_url, + kwargs={ + 'course_id': course_key, + } + ) + if query_params: + base_url = f'{base_url}?{query_params}' + return base_url + + def test_course_exist_with_data(self): + self.submit_question_answer('p1', {'2_1': 'Correct'}) + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get( + self.get_url( + course_key=self.course.id, + ) + ) + assert resp.status_code == status.HTTP_200_OK + resp_json = resp.json()['results'][0] + assert resp_json['course_id'] == str(self.course.id) + assert resp_json['course_name'] == 'test_course' + assert len(resp_json['problems']) > 0 + assert len(resp_json['problems'][0]['submission_history']) > 0 diff --git a/lms/djangoapps/grades/rest_api/v1/urls.py b/lms/djangoapps/grades/rest_api/v1/urls.py index 498b26467e..9a4ab9d1d0 100644 --- a/lms/djangoapps/grades/rest_api/v1/urls.py +++ b/lms/djangoapps/grades/rest_api/v1/urls.py @@ -44,4 +44,14 @@ urlpatterns = [ gradebook_views.SubsectionGradeView.as_view(), name='course_grade_overrides' ), + path( + 'section_grades_breakdown/', + views.SectionGradesBreakdown.as_view(), + name='section_grades_breakdown' + ), + re_path( + fr'submission_history/{settings.COURSE_ID_PATTERN}/', + views.SubmissionHistoryView.as_view(), + name='submission_history' + ), ] diff --git a/lms/djangoapps/grades/rest_api/v1/utils.py b/lms/djangoapps/grades/rest_api/v1/utils.py index 0a13db8572..f2544d3579 100644 --- a/lms/djangoapps/grades/rest_api/v1/utils.py +++ b/lms/djangoapps/grades/rest_api/v1/utils.py @@ -58,6 +58,7 @@ class GradeViewMixin(DeveloperErrorViewMixin): """ Mixin class for Grades related views. """ + def _get_single_user(self, request, course_key, user_id=None): """ Returns a single USER_MODEL object corresponding to either the user_id provided, or if no id is provided, @@ -141,13 +142,47 @@ class GradeViewMixin(DeveloperErrorViewMixin): Returns: A list of users, pulled from a paginated queryset of enrollments, who are enrolled in the given course. """ + paged_enrollments = self._paginate_course_enrollment(course_key, + course_enrollment_filter, related_models, annotations) + return GradeViewMixin._get_enrolled_users(paged_enrollments) + + @staticmethod + def _get_enrolled_users(enrollments): + """ + Args: + enrollments: CourseEnrollment query set. + + Returns: + A list of users, pulled from the CourseEnrollment query set. + """ + retlist = [] + for enrollment in enrollments: + enrollment.user.enrollment_mode = enrollment.mode + retlist.append(enrollment.user) + return retlist + + def _paginate_course_enrollment(self, course_key=None, + course_enrollment_filter=None, related_models=None, annotations=None): + """ + Args: + course_key (CourseLocator): The course to retrieve grades for. + course_enrollment_filter: Optional list of Q objects to pass + to `CourseEnrollment.filter()`. + related_models: Optional list of related models to join to the CourseEnrollment table. + annotations: Optional dict of fields to add to the queryset via annotation + + Returns: + A list of Enrollments, pulled from a paginated queryset. + """ queryset = CourseEnrollment.objects if annotations: queryset = queryset.annotate(**annotations) - filter_args = [ - Q(course_id=course_key) & Q(is_active=True) - ] + filter_args = [Q(is_active=True)] + + if course_key: + filter_args = [Q(course_id=course_key) & Q(is_active=True)] + filter_args.extend(course_enrollment_filter or []) enrollments_in_course = use_read_replica_if_available( @@ -157,11 +192,7 @@ class GradeViewMixin(DeveloperErrorViewMixin): enrollments_in_course = enrollments_in_course.select_related(*related_models) paged_enrollments = self.paginate_queryset(enrollments_in_course) - retlist = [] - for enrollment in paged_enrollments: - enrollment.user.enrollment_mode = enrollment.mode - retlist.append(enrollment.user) - return retlist + return paged_enrollments def _serialize_user_grade(self, user, course_key, course_grade): """ diff --git a/lms/djangoapps/grades/rest_api/v1/views.py b/lms/djangoapps/grades/rest_api/v1/views.py index babd6382f1..5f49f2299a 100644 --- a/lms/djangoapps/grades/rest_api/v1/views.py +++ b/lms/djangoapps/grades/rest_api/v1/views.py @@ -1,21 +1,35 @@ """ API v0 views. """ +import json import logging +from collections import defaultdict from contextlib import contextmanager +from typing import List +from django.core.exceptions import ValidationError # lint-amnesty, pylint: disable=wrong-import-order +from django.db.models import Q from edx_rest_framework_extensions import permissions +from edx_rest_framework_extensions.auth.bearer.authentication import BearerAuthentication from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from rest_framework import status +from rest_framework.authentication import SessionAuthentication from rest_framework.generics import ListAPIView from rest_framework.response import Response +from common.djangoapps.student.models.course_enrollment import CourseEnrollment +from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.courses import get_course +from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades from lms.djangoapps.grades.rest_api.serializers import GradingPolicySerializer from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination, GradeViewMixin +from openedx.core.djangoapps.enrollments.forms import CourseEnrollmentsApiListForm +from openedx.core.djangoapps.enrollments.views import EnrollmentUserThrottle from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.view_utils import PaginatedAPIView, get_course_key, verify_course_exists from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -207,3 +221,318 @@ class CourseGradingPolicy(GradeViewMixin, ListAPIView): def get(self, request, course_id, *args, **kwargs): # pylint: disable=arguments-differ course = self._get_course(request, course_id) return Response(GradingPolicySerializer(course.raw_grader, many=True).data) + + +class SectionGradesBreakdown(GradeViewMixin, PaginatedAPIView): + """ Section grades breakdown gives out the overall grade for a user in a course + accompanied by grades for each section of the course for the user. + """ + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + permission_classes = (permissions.IsStaff,) + pagination_class = CourseEnrollmentPagination + + def get(self, request): # pylint: disable=arguments-differ + """ + **Use Cases** + + Get a list of all grades for all sections, optionally filtered by a course ID or list of usernames. + + **Example Requests** + + GET /api/grades/v1/section_grades_breakdown + + GET /api/grades/v1/section_grades_breakdown?course_id={course_id} + + GET /api/grades/v1/section_grades_breakdown?username={username},{username},{username} + + GET /api/grades/v1/section_grades_breakdown?course_id={course_id}&username={username} + + **Query Parameters for GET** + + * course_id: Filters the result to course grade status for the course corresponding to the + given course ID. The value must be URL encoded. Optional. + + * username: List of comma-separated usernames. Filters the result to the course grade status + of the given users. Optional. + + * page_size: Number of results to return per page. Optional. + + **Response Values** + + If the request for information about the course grade status is successful, an HTTP 200 "OK" response + is returned. + + The HTTP 200 response has the following values. + + * results: A list of the course grading status matching the request. + + * course_id: Course ID of the course in the course grading status. + + * user: Username of the user in the course enrollment. + + * passed: Boolean flag for user passing the course. + + * current_grade: An integer representing the current grade of the course. + + * section_breakdown: A summary of each course section's grade. + + A dictionary in the section_breakdown list has the following keys: + * percent: A float percentage for the section. + * label: A short string identifying the section. Preferably fixed-length. E.g. "HW 3". + * detail: A string explanation of the score. E.g. "Homework 1 - Ohms Law - 83% (5/6)" + * category: A string identifying the category. + * prominent: A boolean value indicating that this section should be displayed as more prominent + than other items. + + * next: The URL to the next page of results, or null if this is the + last page. + + * previous: The URL to the next page of results, or null if this + is the first page. + + If the user is not logged in, a 401 error is returned. + + If the user is not global staff, a 403 error is returned. + + If the specified course_id is not valid or any of the specified usernames + are not valid, a 400 error is returned. + + If the specified course_id does not correspond to a valid course or if all the specified + usernames do not correspond to valid users, an HTTP 200 "OK" response is returned with an + empty 'results' field. + """ + course_grading_status = [] + username_filter = [] + + form = CourseEnrollmentsApiListForm(self.request.query_params) + if not form.is_valid(): + raise ValidationError(form.errors) + usernames = form.cleaned_data.get('username') + course_id = form.cleaned_data.get('course_id') + if usernames: + username_filter = [Q(user__username__in=usernames)] + course_enrollments = self._paginate_course_enrollment(course_id, course_enrollment_filter=username_filter) + enrolled_course_user_map = SectionGradesBreakdown._get_enrolled_course_user_map(course_enrollments) + + for course_key, users in enrolled_course_user_map.items(): + with bulk_course_grade_context(course_key, users): + for user, course_grade, exc in CourseGradeFactory().iter(users, course_key=course_key): + if not exc: + course_grading_status.append( + SectionGradesBreakdown._serialize_section_grades(user, course_key, course_grade) + ) + return self.get_paginated_response(course_grading_status) + + @staticmethod + def _get_enrolled_course_user_map(enrollments): + """ Returns a map of courses with all the users enrolled in them. + """ + enrolled_course_user_map = defaultdict(list) + for enrollment in enrollments: + enrolled_course_user_map[enrollment.course_id].append(enrollment.user) + return enrolled_course_user_map + + @staticmethod + def _serialize_section_grades(user, course_key, course_grade): + """ + Convert the extracted information into a serialized structure. + + Returns a dictionary with the following information about the course & course grade. + * course_id: Course id of the given course. + * username: Username of the user on the platform. + * passed: If the user passed the course or not. + * current_grade: An integer representing the current grade of the course. + * section_breakdown: A summary of each course section's grade. + """ + summary = [] + for section in course_grade.summary.get('section_breakdown'): + summary.append(section) + course_grading_status = { + 'course_id': str(course_key), + 'username': user.username, + 'passed': course_grade.passed, + 'current_grade': int(course_grade.percent * 100), + 'section_breakdown': summary, + } + return course_grading_status + + +@can_disable_rate_limit +class SubmissionHistoryView(GradeViewMixin, PaginatedAPIView): + """ + Submission history corresponding to ProblemBlocks present in the course. + """ + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + permission_classes = (permissions.IsStaff,) + throttle_classes = (EnrollmentUserThrottle,) + pagination_class = CourseEnrollmentPagination + + def get(self, request, course_id=None): + """ + Get submission history details. This submission history is related to only + ProblemBlock and it doesn't support LibraryContentBlock or ContentLibraries + as of now. + + **Usecases**: + + Users with GlobalStaff status can retrieve everyone's submission history. + + **Example Requests**: + + GET /api/grades/v1/submission_history/{course_id} + GET /api/grades/v1/submission_history/{course_id}/?username={username} + + **Query Parameters for GET** + + * course_id: Course id to retrieve submission history. + * username: Single username for which this view will retrieve the submission history details. + + **Response Values**: + + If there's an error while getting the submission history an empty response will + be returned. + The submission history response has the following attributes: + + * Results: A list of submission history: + * course_id: Course id + * course_name: Course name + * user: Username + * problems: List of problems + * location: problem location + * name: problem's display name + * submission_history: List of submission history + * state: State of submission. + * grade: Grade. + * max_grade: Maximum possible grade. + * data: problem's data. + """ + data = [] + username_filter = [] + username = request.GET.get('username') + try: + course_id = get_course_key(request, course_id) + except InvalidKeyError: + raise self.api_error( # lint-amnesty, pylint: disable=raise-missing-from + status_code=status.HTTP_400_BAD_REQUEST, + developer_message='The provided course key cannot be parsed.', + error_code='invalid_course_key' + ) + + if username: + username_filter = [Q(user__username=username)] + course_enrollments = self._paginate_course_enrollment(course_id, course_enrollment_filter=username_filter) + + course_xblock_structure = SubmissionHistoryView._generate_course_structure(course_enrollments) + for course_key, course_info in course_xblock_structure.items(): + course_data = SubmissionHistoryView._get_course_data( + course_key, + course_info.get('course_enrollments'), + course_info.get('course'), + course_info.get('blocks') + ) + data.extend(course_data) + return self.get_paginated_response(data) + + @staticmethod + def _generate_course_structure(enrollments): + """ Generate a map of course to course enrollment and problem + xblocks for each of the course. + """ + course_enrollment_id_map = defaultdict(list) + course_xblock_structure = {} + for course_enrollment in enrollments: + course_enrollment_id_map[str(course_enrollment.course_id)].append(course_enrollment) + for course_key, course_enrollments in course_enrollment_id_map.items(): + course_id = CourseKey.from_string(course_key) + course = get_course(course_id, depth=4) + course_xblock_structure[course_key] = { + 'course_enrollments': course_enrollments, + 'blocks': SubmissionHistoryView.get_problem_blocks(course), + 'course': course + } + return course_xblock_structure + + @staticmethod + def get_problem_blocks(course): + """ Get a list of problem xblock for the course. + This doesn't support LibraryContentBlock or ContentLibraries + as of now + """ + blocks = [] + for section in course.get_children(): + for subsection in section.get_children(): + for vertical in subsection.get_children(): + for block in vertical.get_children(): + if block.category == 'problem' and getattr(block, 'has_score', False): + blocks.append(block) + return blocks + + @staticmethod + def _get_course_data(course_key: str, course_enrollments: List[CourseEnrollment], course, blocks): + """ + Extracts the fields needed from course enrollments and course block. + This function maps the ProblemBlock data of the course to it's enrollment. + + Params: + -------- + course: course + block: XBlock to analyze. + """ + course_grouped_data = [] + for course_enrollment in course_enrollments: + course_data = { + 'course_id': course_key, + 'course_name': course.display_name_with_default, + 'user': course_enrollment.user.username, + 'problems': [] + } + for block in blocks: + problem_data = SubmissionHistoryView._get_problem_data(course_enrollment, block) + if problem_data["submission_history"]: + course_data['problems'].append(problem_data) + course_grouped_data.append(course_data) + return course_grouped_data + + @staticmethod + def _get_problem_data(course_enrollment: CourseEnrollment, block): + """ + Get problem data from a course enrollment. + + Args: + ----- + block: XBlock to analyze. + """ + problem_data = { + 'location': str(block.scope_ids.usage_id), + 'name': block.display_name, + 'submission_history': [], + 'data': block.data + } + csm = StudentModule.objects.filter( + module_state_key=block.location, + student=course_enrollment.user, + course_id=course_enrollment.course_id + ) + + scores = BaseStudentModuleHistory.get_history(csm) + for score in scores: + state = score.state + if state is not None: + state = json.loads(state) + + history_data = { + 'state': state, + 'grade': score.grade, + 'max_grade': score.max_grade + } + problem_data['submission_history'].append(history_data) + + return problem_data