diff --git a/lms/djangoapps/grades/services.py b/lms/djangoapps/grades/services.py index 074926c9fc..5350fcf378 100644 --- a/lms/djangoapps/grades/services.py +++ b/lms/djangoapps/grades/services.py @@ -3,9 +3,13 @@ Grade service """ from datetime import datetime +from django.contrib.auth import get_user_model import pytz from six import text_type +from lms.djangoapps.courseware.courses import get_course +from lms.djangoapps.grades.course_data import CourseData +from lms.djangoapps.grades.subsection_grade import CreateSubsectionGrade from lms.djangoapps.utils import _get_key from opaque_keys.edx.keys import CourseKey, UsageKey from track.event_transaction_utils import create_new_event_transaction_id, set_event_transaction_type @@ -21,6 +25,9 @@ from .models import ( from .signals.signals import SUBSECTION_OVERRIDE_CHANGED +USER_MODEL = get_user_model() + + class GradesService(object): """ Course grade service @@ -55,21 +62,29 @@ class GradesService(object): return PersistentSubsectionGradeOverride.get_override(user_id, usage_key) - def override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id, earned_all=None, - earned_graded=None): + def override_subsection_grade( + self, user_id, course_key_or_id, usage_key_or_id, earned_all=None, earned_graded=None + ): """ - Override subsection grade (the PersistentSubsectionGrade model must already exist) + Creates a PersistentSubsectionGradeOverride corresponding to the given + user, course, and usage_key. + Will also create a ``PersistentSubsectionGrade`` for this (user, course, usage_key) + if none currently exists. - Fires off a recalculate_subsection_grade async task to update the PersistentSubsectionGrade table. Will not - override earned_all or earned_graded value if they are None. Both default to None. + Fires off a recalculate_subsection_grade async task to update the PersistentCourseGrade table. + Will not override ``earned_all`` or ``earned_graded`` value if they are ``None``. + Both of these parameters have ``None`` as their default value. """ course_key = _get_key(course_key_or_id, CourseKey) usage_key = _get_key(usage_key_or_id, UsageKey) - grade = PersistentSubsectionGrade.read_grade( - user_id=user_id, - usage_key=usage_key - ) + try: + grade = PersistentSubsectionGrade.read_grade( + user_id=user_id, + usage_key=usage_key + ) + except PersistentSubsectionGrade.DoesNotExist: + grade = self._create_subsection_grade(user_id, course_key, usage_key) override = PersistentSubsectionGradeOverride.update_or_create_override( requesting_user=None, @@ -83,8 +98,8 @@ class GradesService(object): create_new_event_transaction_id() set_event_transaction_type(SUBSECTION_OVERRIDE_EVENT_TYPE) - # Signal will trigger subsection recalculation which will call PersistentSubsectionGrade.update_or_create_grade - # which will use the above override to update the grade before writing to the table. + # This will eventually trigger a re-computation of the course grade, + # taking the new PersistentSubsectionGradeOverride into account. SUBSECTION_OVERRIDE_CHANGED.send( sender=None, user_id=user_id, @@ -142,3 +157,17 @@ class GradesService(object): """Convienence function to return the state of the CourseWaffleFlag REJECTED_EXAM_OVERRIDES_GRADE""" course_key = _get_key(course_key_or_id, CourseKey) return waffle_flags()[REJECTED_EXAM_OVERRIDES_GRADE].is_enabled(course_key) + + def _create_subsection_grade(self, user_id, course_key, usage_key): + """ + Given a user_id, course_key, and subsection usage_key, + creates a new ``PersistentSubsectionGrade``. + """ + course = get_course(course_key, depth=None) + subsection = course.get_child(usage_key) + if not subsection: + raise Exception('Subsection with given usage_key does not exist.') + user = USER_MODEL.objects.get(id=user_id) + course_data = CourseData(user, course=course) + subsection_grade = CreateSubsectionGrade(subsection, course_data.structure, {}, {}) + return subsection_grade.update_or_create_model(user, force_update_subsections=True) diff --git a/lms/djangoapps/grades/tests/test_services.py b/lms/djangoapps/grades/tests/test_services.py index 3f1a909b58..d4b0e7410f 100644 --- a/lms/djangoapps/grades/tests/test_services.py +++ b/lms/djangoapps/grades/tests/test_services.py @@ -21,6 +21,9 @@ from ..constants import ScoreDatabaseTableEnum class MockWaffleFlag(object): + """ + A Mock WaffleFlag object. + """ def __init__(self, state): self.state = state @@ -40,6 +43,11 @@ class GradesServiceTests(ModuleStoreTestCase): self.service = GradesService() self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course', run='Spring2019') self.subsection = ItemFactory.create(parent=self.course, category="subsection", display_name="Subsection") + self.subsection_without_grade = ItemFactory.create( + parent=self.course, + category="subsection", + display_name="Subsection without grade" + ) self.user = UserFactory() self.grade = PersistentSubsectionGrade.update_or_create_grade( user_id=self.user.id, @@ -66,6 +74,7 @@ class GradesServiceTests(ModuleStoreTestCase): } def tearDown(self): + super(GradesServiceTests, self).tearDown() PersistentSubsectionGradeOverride.objects.all().delete() # clear out all previous overrides self.signal_patcher.stop() self.id_patcher.stop() @@ -140,37 +149,24 @@ class GradesServiceTests(ModuleStoreTestCase): self.assertEqual(override_history.action, history_action) @ddt.data( - [{ + { 'earned_all': 0.0, 'earned_graded': 0.0 - }, { - 'earned_all': 0.0, - 'earned_graded': 0.0 - }], - [{ + }, + { 'earned_all': 0.0, 'earned_graded': None - }, { - 'earned_all': 0.0, - 'earned_graded': 5.0 - }], - [{ + }, + { 'earned_all': None, 'earned_graded': None - }, { - 'earned_all': 6.0, - 'earned_graded': 5.0 - }], - [{ + }, + { 'earned_all': 3.0, 'earned_graded': 2.0 - }, { - 'earned_all': 3.0, - 'earned_graded': 2.0 - }], + }, ) - @ddt.unpack - def test_override_subsection_grade(self, override, expected): + def test_override_subsection_grade(self, override): self.service.override_subsection_grade( user_id=self.user.id, course_key_or_id=self.course.id, @@ -204,6 +200,57 @@ class GradesServiceTests(ModuleStoreTestCase): override_history = PersistentSubsectionGradeOverrideHistory.objects.filter(override_id=override_obj.id).first() self._verify_override_history(override_history, PersistentSubsectionGradeOverrideHistory.CREATE_OR_UPDATE) + def test_override_subsection_grade_no_psg(self): + """ + When there is no PersistentSubsectionGrade associated with the learner + and subsection to override, one should be created. + """ + earned_all_override = 2 + earned_graded_override = 0 + self.service.override_subsection_grade( + user_id=self.user.id, + course_key_or_id=self.course.id, + usage_key_or_id=self.subsection_without_grade.location, + earned_all=earned_all_override, + earned_graded=earned_graded_override + ) + + # Assert that a new PersistentSubsectionGrade was created + subsection_grade = self.service.get_subsection_grade( + self.user.id, + self.course.id, + self.subsection_without_grade.location + ) + self.assertIsNotNone(subsection_grade) + self.assertEqual(0, subsection_grade.earned_all) + self.assertEqual(0, subsection_grade.earned_graded) + + # Now assert things about the grade override + override_obj = self.service.get_subsection_grade_override( + self.user.id, + self.course.id, + self.subsection_without_grade.location + ) + self.assertIsNotNone(override_obj) + self.assertEqual(override_obj.earned_all_override, earned_all_override) + self.assertEqual(override_obj.earned_graded_override, earned_graded_override) + + self.assertEqual( + self.mock_signal.call_args, + call( + sender=None, + user_id=self.user.id, + course_id=unicode(self.course.id), + usage_id=unicode(self.subsection_without_grade.location), + only_if_higher=False, + modified=override_obj.modified, + score_deleted=False, + score_db_table=ScoreDatabaseTableEnum.overrides + ) + ) + override_history = PersistentSubsectionGradeOverrideHistory.objects.filter(override_id=override_obj.id).first() + self._verify_override_history(override_history, PersistentSubsectionGradeOverrideHistory.CREATE_OR_UPDATE) + @freeze_time('2017-01-01') def test_undo_override_subsection_grade(self): override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(grade=self.grade)