Commit be8a56cd by sanfordstudent Committed by GitHub

Merge pull request #13804 from edx/beryl/course_grades_glue

Beryl/course grades glue
parents c6b6d0bc a1d5ea90
......@@ -229,18 +229,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default,
# # of mongo queries,
# )
('no_overrides', 1, True, False): (17, 6),
('no_overrides', 2, True, False): (17, 6),
('no_overrides', 3, True, False): (17, 6),
('ccx', 1, True, False): (17, 6),
('ccx', 2, True, False): (17, 6),
('ccx', 3, True, False): (17, 6),
('no_overrides', 1, False, False): (17, 6),
('no_overrides', 2, False, False): (17, 6),
('no_overrides', 3, False, False): (17, 6),
('ccx', 1, False, False): (17, 6),
('ccx', 2, False, False): (17, 6),
('ccx', 3, False, False): (17, 6),
('no_overrides', 1, True, False): (18, 6),
('no_overrides', 2, True, False): (18, 6),
('no_overrides', 3, True, False): (18, 6),
('ccx', 1, True, False): (18, 6),
('ccx', 2, True, False): (18, 6),
('ccx', 3, True, False): (18, 6),
('no_overrides', 1, False, False): (18, 6),
('no_overrides', 2, False, False): (18, 6),
('no_overrides', 3, False, False): (18, 6),
('ccx', 1, False, False): (18, 6),
('ccx', 2, False, False): (18, 6),
('ccx', 3, False, False): (18, 6),
}
......@@ -252,19 +252,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True
TEST_DATA = {
('no_overrides', 1, True, False): (17, 3),
('no_overrides', 2, True, False): (17, 3),
('no_overrides', 3, True, False): (17, 3),
('ccx', 1, True, False): (17, 3),
('ccx', 2, True, False): (17, 3),
('ccx', 3, True, False): (17, 3),
('ccx', 1, True, True): (18, 3),
('ccx', 2, True, True): (18, 3),
('ccx', 3, True, True): (18, 3),
('no_overrides', 1, False, False): (17, 3),
('no_overrides', 2, False, False): (17, 3),
('no_overrides', 3, False, False): (17, 3),
('ccx', 1, False, False): (17, 3),
('ccx', 2, False, False): (17, 3),
('ccx', 3, False, False): (17, 3),
('no_overrides', 1, True, False): (18, 3),
('no_overrides', 2, True, False): (18, 3),
('no_overrides', 3, True, False): (18, 3),
('ccx', 1, True, False): (18, 3),
('ccx', 2, True, False): (18, 3),
('ccx', 3, True, False): (18, 3),
('ccx', 1, True, True): (19, 3),
('ccx', 2, True, True): (19, 3),
('ccx', 3, True, True): (19, 3),
('no_overrides', 1, False, False): (18, 3),
('no_overrides', 2, False, False): (18, 3),
('no_overrides', 3, False, False): (18, 3),
('ccx', 1, False, False): (18, 3),
('ccx', 2, False, False): (18, 3),
('ccx', 3, False, False): (18, 3),
}
......@@ -86,7 +86,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase):
# The student progress tab is not accessible to a student
# before launch, so the instructor view-as-student feature
# should return a 404 as well.
# should return a 403.
# TODO (vshnayder): If this is not the behavior we want, will need
# to make access checking smarter and understand both the effective
# user (the student), and the requesting user (the prof)
......@@ -97,7 +97,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase):
'student_id': self.enrolled_user.id,
}
)
self.assert_request_status_code(404, url)
self.assert_request_status_code(403, url)
# The courseware url should redirect, not 200
url = self._reverse_urls(['courseware'], course)[0]
......
......@@ -1345,17 +1345,17 @@ class ProgressPageTests(ModuleStoreTestCase):
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced)
with self.assertNumQueries(34), check_mongo_calls(4):
with self.assertNumQueries(35), check_mongo_calls(4):
self._get_progress_page()
def test_progress_queries(self):
self.setup_course()
with self.assertNumQueries(34), check_mongo_calls(4):
with self.assertNumQueries(35), check_mongo_calls(4):
self._get_progress_page()
# subsequent accesses to the progress page require fewer queries.
for _ in range(2):
with self.assertNumQueries(20), check_mongo_calls(4):
with self.assertNumQueries(21), check_mongo_calls(4):
self._get_progress_page()
@patch(
......
......@@ -720,10 +720,6 @@ def _progress(request, course_key, student_id):
student = User.objects.prefetch_related("groups").get(id=student.id)
course_grade = CourseGradeFactory(student).create(course)
if not course_grade.has_access_to_course:
# This means the student didn't have access to the course (which the instructor requested)
raise Http404
courseware_summary = course_grade.chapter_grades
grade_summary = course_grade.summary
......
......@@ -149,14 +149,6 @@ class UserGradeView(GradeViewMixin, GenericAPIView):
prep_course_for_grading(course, request)
course_grade = CourseGradeFactory(request.user).create(course)
if not course_grade.has_access_to_course:
# This means the student didn't have access to the course
log.info('User %s not allowed to access grade for course %s', request.user.username, username)
return self.make_error_response(
status_code=status.HTTP_403_FORBIDDEN,
developer_message='The user does not have access to the course.',
error_code='user_does_not_have_access_to_course'
)
return Response([{
'username': username,
......
......@@ -4,6 +4,7 @@ CourseGrade Class
from collections import defaultdict
from django.conf import settings
from django.core.exceptions import PermissionDenied
from lazy import lazy
from logging import getLogger
from lms.djangoapps.course_blocks.api import get_course_blocks
......@@ -11,7 +12,9 @@ from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
from xmodule import block_metadata_utils
from ..models import PersistentCourseGrade
from .subsection_grade import SubsectionGradeFactory
from ..transformer import GradesTransformer
log = getLogger(__name__)
......@@ -24,8 +27,12 @@ class CourseGrade(object):
def __init__(self, student, course, course_structure):
self.student = student
self.course = course
self.course_version = getattr(course, 'course_version', None)
self.course_edited_timestamp = getattr(course, 'subtree_edited_on', None)
self.course_structure = course_structure
self.chapter_grades = []
self._percent = None
self._letter_grade = None
self._subsection_grade_factory = SubsectionGradeFactory(self.student, self.course, self.course_structure)
@lazy
def subsection_grade_totals_by_format(self):
......@@ -70,27 +77,46 @@ class CourseGrade(object):
self._log_event(log.warning, u"grade_value, percent: {0}, grade: {1}".format(percent, letter_grade))
return grade_value
@property
def has_access_to_course(self):
@lazy
def chapter_grades(self):
"""
Returns whether the course structure as seen by the
given student is non-empty.
Returns a list of chapters, each containing its subsection grades,
display name, and url name.
"""
return len(self.course_structure) > 0
chapter_grades = []
for chapter_key in self.course_structure.get_children(self.course.location):
chapter = self.course_structure[chapter_key]
chapter_subsection_grades = []
children = self.course_structure.get_children(chapter_key)
for subsection_key in children:
chapter_subsection_grades.append(
self._subsection_grade_factory.create(self.course_structure[subsection_key], read_only=True)
)
chapter_grades.append({
'display_name': block_metadata_utils.display_name_with_default_escaped(chapter),
'url_name': block_metadata_utils.url_name_for_block(chapter),
'sections': chapter_subsection_grades
})
return chapter_grades
@property
def percent(self):
"""
Returns a rounded percent from the overall grade.
"""
return self._calc_percent(self.grade_value)
if self._percent is None:
self._percent = self._calc_percent(self.grade_value)
return self._percent
@property
def letter_grade(self):
"""
Returns a letter representing the grade.
"""
return self._compute_letter_grade(self.percent)
if self._letter_grade is None:
self._letter_grade = self._compute_letter_grade(self.percent)
return self._letter_grade
@property
def passed(self):
......@@ -107,9 +133,6 @@ class CourseGrade(object):
Returns the grade summary as calculated by the course's grader.
"""
grade_summary = self.grade_value
# We round the grade here, to make sure that the grade is a whole percentage and
# doesn't get displayed differently than it gets grades
grade_summary['percent'] = self.percent
grade_summary['grade'] = self.letter_grade
grade_summary['totaled_scores'] = self.subsection_grade_totals_by_format
......@@ -123,30 +146,24 @@ class CourseGrade(object):
If read_only is True, doesn't save any updates to the grades.
"""
subsection_grade_factory = SubsectionGradeFactory(self.student, self.course, self.course_structure)
subsections_total = 0
for chapter_key in self.course_structure.get_children(self.course.location):
chapter = self.course_structure[chapter_key]
chapter_subsection_grades = []
children = self.course_structure.get_children(chapter_key)
subsections_total += len(children)
for subsection_key in children:
chapter_subsection_grades.append(
subsection_grade_factory.create(self.course_structure[subsection_key], read_only=True)
)
self.chapter_grades.append({
'display_name': block_metadata_utils.display_name_with_default_escaped(chapter),
'url_name': block_metadata_utils.url_name_for_block(chapter),
'sections': chapter_subsection_grades
})
subsections_total = sum(len(chapter['sections']) for chapter in self.chapter_grades)
total_graded_subsections = sum(len(x) for x in self.subsection_grade_totals_by_format.itervalues())
subsections_created = len(subsection_grade_factory._unsaved_subsection_grades) # pylint: disable=protected-access
subsections_created = len(self._subsection_grade_factory._unsaved_subsection_grades) # pylint: disable=protected-access
subsections_read = subsections_total - subsections_created
blocks_total = len(self.locations_to_scores)
if not read_only:
subsection_grade_factory.bulk_create_unsaved()
self._subsection_grade_factory.bulk_create_unsaved()
grading_policy_hash = self.get_grading_policy_hash(self.course.location, self.course_structure)
PersistentCourseGrade.update_or_create_course_grade(
user_id=self.student.id,
course_id=self.course.id,
course_version=self.course_version,
course_edited_timestamp=self.course_edited_timestamp,
grading_policy_hash=grading_policy_hash,
percent_grade=self.percent,
letter_grade=self.letter_grade or "",
)
self._signal_listeners_when_grade_computed()
self._log_event(
......@@ -184,6 +201,46 @@ class CourseGrade(object):
return earned, possible
@staticmethod
def get_grading_policy_hash(course_location, course_structure):
"""
Gets the grading policy of the course at the given location
in the given course structure.
"""
return course_structure.get_transformer_block_field(
course_location,
GradesTransformer,
'grading_policy_hash'
)
@classmethod
def load_persisted_grade(cls, user, course, course_structure):
"""
Initializes a CourseGrade object, filling its members with persisted values from the database.
If the grading policy is out of date, recomputes the grade.
If no persisted values are found, returns None.
"""
try:
persistent_grade = PersistentCourseGrade.read_course_grade(user.id, course.id)
except PersistentCourseGrade.DoesNotExist:
return None
course_grade = CourseGrade(user, course, course_structure)
current_grading_policy_hash = course_grade.get_grading_policy_hash(course.location, course_structure)
if current_grading_policy_hash != persistent_grade.grading_policy_hash:
return None
else:
course_grade._percent = persistent_grade.percent_grade # pylint: disable=protected-access
course_grade._letter_grade = persistent_grade.letter_grade # pylint: disable=protected-access
course_grade.course_version = persistent_grade.course_version
course_grade.course_edited_timestamp = persistent_grade.course_edited_timestamp
course_grade._log_event(log.info, u"load_persisted_grade") # pylint: disable=protected-access
return course_grade
@staticmethod
def _calc_percent(grade_value):
"""
Helper for percent calculation.
......@@ -253,8 +310,12 @@ class CourseGradeFactory(object):
Returns the CourseGrade object for the given student and course.
If read_only is True, doesn't save any updates to the grades.
Raises a PermissionDenied if the user does not have course access.
"""
course_structure = get_course_blocks(self.student, course.location)
# if user does not have access to this course, throw an exception
if not self._user_has_access_to_course(course_structure):
raise PermissionDenied("User does not have access to this course")
return (
self._get_saved_grade(course, course_structure) or
self._compute_and_update_grade(course, course_structure, read_only)
......@@ -281,13 +342,19 @@ class CourseGradeFactory(object):
"""
Returns the saved grade for the given course and student.
"""
if PersistentGradesEnabledFlag.feature_enabled(course.id):
# TODO LATER Retrieve the saved grade for the course, if it exists.
_pretend_to_save_course_grades()
if not PersistentGradesEnabledFlag.feature_enabled(course.id):
return None
return CourseGrade.load_persisted_grade(
self.student,
course,
course_structure
)
def _pretend_to_save_course_grades():
"""
Stub to facilitate testing feature flag until robust grade work lands.
"""
pass
def _user_has_access_to_course(self, course_structure):
"""
Given a course structure, returns whether the user
for whom that course structure was retrieved
has access to the course.
"""
return len(course_structure) > 0
......@@ -48,7 +48,8 @@ class GradeTestBase(SharedModuleStoreTestCase):
parent=cls.chapter,
category='sequential',
display_name="Test Sequential 1",
graded=True
graded=True,
format="Homework"
)
cls.vertical = ItemFactory.create(
parent=cls.sequence,
......@@ -100,13 +101,35 @@ class TestCourseGradeFactory(GradeTestBase):
course_id=self.course.id,
enabled_for_course=course_setting
):
with patch('lms.djangoapps.grades.new.course_grade._pretend_to_save_course_grades') as mock_save_grades:
with patch('lms.djangoapps.grades.new.course_grade.CourseGrade.load_persisted_grade') as mock_save_grades:
grade_factory.create(self.course)
self.assertEqual(mock_save_grades.called, feature_flag and course_setting)
def test_course_grade_creation(self):
grading_policy = {
"GRADER": [
{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "HW",
"weight": 1.0,
},
],
"GRADE_CUTOFFS": {
"Pass": 0.5,
},
}
self.course.set_grading_policy(grading_policy)
grade_factory = CourseGradeFactory(self.request.user)
with mock_get_score(1, 2):
course_grade = grade_factory.create(self.course)
self.assertEqual(course_grade.letter_grade, u'Pass')
self.assertEqual(course_grade.percent, 0.5)
@ddt.ddt
class TestSubsectionGradeFactory(GradeTestBase, ProblemSubmissionTestMixin):
class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
"""
Tests for SubsectionGradeFactory functionality.
......@@ -456,7 +479,7 @@ class TestVariedMetadata(ProblemSubmissionTestMixin, ModuleStoreTestCase):
self.assertEqual(score.graded_total.possible, expected_possible)
class TestCourseGradeLogging(SharedModuleStoreTestCase):
class TestCourseGradeLogging(ProblemSubmissionTestMixin, SharedModuleStoreTestCase):
"""
Tests logging in the course grades module.
Uses a larger course structure than other
......@@ -504,26 +527,26 @@ class TestCourseGradeLogging(SharedModuleStoreTestCase):
display_name='Test Vertical 3'
)
problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
question_text='The correct answer is Choice 3',
question_text='The correct answer is Choice 2',
choices=[False, False, True, False],
choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
)
self.problem = ItemFactory.create(
parent=self.vertical,
category="problem",
display_name="Test Problem 1",
display_name="test_problem_1",
data=problem_xml
)
self.problem_2 = ItemFactory.create(
parent=self.vertical_2,
category="problem",
display_name="Test Problem 2",
display_name="test_problem_2",
data=problem_xml
)
self.problem_3 = ItemFactory.create(
parent=self.vertical_3,
category="problem",
display_name="Test Problem 3",
display_name="test_problem_3",
data=problem_xml
)
self.request = get_request_for_user(UserFactory())
......@@ -536,28 +559,14 @@ class TestCourseGradeLogging(SharedModuleStoreTestCase):
self,
factory,
log_mock,
read_only,
subsections_read,
subsections_created,
blocks_accessed,
total_graded_subsections
log_statement
):
"""
Creates a course grade and asserts that the associated logging
matches the expected totals passed in to the function.
"""
factory.create(self.course, read_only=False)
log_statement = u''.join((
u"compute_and_update, read_only: {0}, subsections read/created: {1}/{2}, blocks ",
u"accessed: {3}, total graded subsections: {4}"
)).format(
read_only,
subsections_read,
subsections_created,
blocks_accessed,
total_graded_subsections,
)
log_mock.warning.assert_called_with(
log_mock.assert_called_with(
u"Persistent Grades: CourseGrade.{0}, course: {1}, user: {2}".format(
log_statement,
unicode(self.course.id),
......@@ -574,24 +583,36 @@ class TestCourseGradeLogging(SharedModuleStoreTestCase):
enabled_for_course=True
):
with patch('lms.djangoapps.grades.new.course_grade.log') as log_mock:
# TODO: once merged with the "glue code" PR, update expected logging to include the relevant new info
# the course grade has not been created, so we expect each grade to be created
log_statement = u''.join((
u"compute_and_update, read_only: {0}, subsections read/created: {1}/{2}, blocks ",
u"accessed: {3}, total graded subsections: {4}"
)).format(False, 0, 3, 3, 2)
self._create_course_grade_and_check_logging(
grade_factory,
log_mock.warning,
log_statement
)
log_mock.reset_mock()
# the course grade has been created, so we expect to read it from the db
log_statement = u"load_persisted_grade"
self._create_course_grade_and_check_logging(
grade_factory,
log_mock,
read_only=False,
subsections_read=0,
subsections_created=3,
blocks_accessed=3,
total_graded_subsections=2)
# the course grade has been created, so we expect each grade to be read
log_mock.info,
log_statement
)
log_mock.reset_mock()
# only problem submission, a subsection grade update triggers
# a course grade update
self.submit_question_answer(u'test_problem_1', {u'2_1': u'choice_choice_2'})
log_statement = u''.join((
u"compute_and_update, read_only: {0}, subsections read/created: {1}/{2}, blocks ",
u"accessed: {3}, total graded subsections: {4}"
)).format(False, 3, 0, 3, 2)
self._create_course_grade_and_check_logging(
grade_factory,
log_mock,
read_only=False,
subsections_read=3,
subsections_created=0,
blocks_accessed=3,
total_graded_subsections=2,
log_mock.warning,
log_statement
)
......@@ -146,7 +146,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
with self.store.default_store(default_store):
self.set_up_course()
self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
with check_mongo_calls(2) and self.assertNumQueries(21 + added_queries):
with check_mongo_calls(2) and self.assertNumQueries(25 + added_queries):
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
def test_single_call_to_create_block_structure(self):
......@@ -170,7 +170,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
ItemFactory.create(parent=self.sequential, category='problem', display_name='problem2')
ItemFactory.create(parent=self.sequential, category='problem', display_name='problem3')
with check_mongo_calls(2) and self.assertNumQueries(21 + added_queries):
with check_mongo_calls(2) and self.assertNumQueries(25 + added_queries):
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
......@@ -179,7 +179,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.set_up_course(enable_subsection_grades=False)
self.assertFalse(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
additional_queries = 1 if default_store == ModuleStoreEnum.Type.mongo else 0
with check_mongo_calls(2) and self.assertNumQueries(12 + additional_queries):
with check_mongo_calls(2) and self.assertNumQueries(16 + additional_queries):
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
@skip("Pending completion of TNL-5089")
......
......@@ -1674,7 +1674,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
'failed': 3,
'skipped': 2
}
with self.assertNumQueries(158):
with self.assertNumQueries(166):
self.assertCertificatesGenerated(task_input, expected_results)
expected_results = {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment