diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index efbfab7de6..3c215bbff2 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -18,7 +18,7 @@ from django.test.client import RequestFactory from django.test.utils import override_settings from edx_proctoring.api import create_exam, create_exam_attempt, update_attempt_status from edx_proctoring.runtime import set_runtime_service -from edx_proctoring.tests.test_services import MockCreditService +from edx_proctoring.tests.test_services import MockCreditService, MockGradesService from freezegun import freeze_time from milestones.tests.utils import MilestonesTestCaseMixin from mock import MagicMock, Mock, patch @@ -994,6 +994,11 @@ class TestProctoringRendering(SharedModuleStoreTestCase): MockCreditService(enrollment_mode=enrollment_mode) ) + set_runtime_service( + 'grades', + MockGradesService() + ) + exam_id = create_exam( course_id=unicode(self.course_key), content_id=unicode(sequence.location), diff --git a/lms/djangoapps/coursewarehistoryextended/fields.py b/lms/djangoapps/coursewarehistoryextended/fields.py index a001543d31..ee5d71a1e5 100644 --- a/lms/djangoapps/coursewarehistoryextended/fields.py +++ b/lms/djangoapps/coursewarehistoryextended/fields.py @@ -3,6 +3,7 @@ Custom fields for use in the coursewarehistoryextended django app. """ from django.db.models.fields import AutoField +from django.db.models.fields.related import OneToOneField class UnsignedBigIntAutoField(AutoField): @@ -23,3 +24,31 @@ class UnsignedBigIntAutoField(AutoField): return "BIGSERIAL" else: return None + + # rel_db_type was added in Django 1.10. For versions before, use UnsignedBigIntOneToOneField. + def rel_db_type(self, connection): + if connection.settings_dict['ENGINE'] == 'django.db.backends.mysql': + return "bigint UNSIGNED" + elif connection.settings_dict['ENGINE'] == 'django.db.backends.sqlite3': + return "integer" + elif connection.settings_dict['ENGINE'] == 'django.db.backends.postgresql_psycopg2': + return "BIGSERIAL" + else: + return None + + +class UnsignedBigIntOneToOneField(OneToOneField): + """ + An unsigned 8-byte integer one-to-one foreign key to a unsigned 8-byte integer id field. + + Should only be necessary for versions of Django < 1.10. + """ + def db_type(self, connection): + if connection.settings_dict['ENGINE'] == 'django.db.backends.mysql': + return "bigint UNSIGNED" + elif connection.settings_dict['ENGINE'] == 'django.db.backends.sqlite3': + return "integer" + elif connection.settings_dict['ENGINE'] == 'django.db.backends.postgresql_psycopg2': + return "BIGSERIAL" + else: + return None diff --git a/lms/djangoapps/grades/config/waffle.py b/lms/djangoapps/grades/config/waffle.py index 95c669e1d8..5029ff3c88 100644 --- a/lms/djangoapps/grades/config/waffle.py +++ b/lms/djangoapps/grades/config/waffle.py @@ -2,7 +2,7 @@ This module contains various configuration settings via waffle switches for the Grades app. """ -from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace, WaffleFlagNamespace, CourseWaffleFlag # Namespace WAFFLE_NAMESPACE = u'grades' @@ -13,9 +13,27 @@ ASSUME_ZERO_GRADE_IF_ABSENT = u'assume_zero_grade_if_absent' ESTIMATE_FIRST_ATTEMPTED = u'estimate_first_attempted' DISABLE_REGRADE_ON_POLICY_CHANGE = u'disable_regrade_on_policy_change' +# Course Flags +REJECTED_EXAM_OVERRIDES_GRADE = u'rejected_exam_overrides_grade' + def waffle(): """ Returns the namespaced, cached, audited Waffle class for Grades. """ return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'Grades: ') + + +def waffle_flags(): + """ + Returns the namespaced, cached, audited Waffle flags dictionary for Grades. + """ + namespace = WaffleFlagNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'Grades: ') + return { + # By default, enable rejected exam grade overrides. Can be disabled on a course-by-course basis. + REJECTED_EXAM_OVERRIDES_GRADE: CourseWaffleFlag( + namespace, + REJECTED_EXAM_OVERRIDES_GRADE, + flag_undefined_default=True + ) + } diff --git a/lms/djangoapps/grades/constants.py b/lms/djangoapps/grades/constants.py index 2ac18f3ddb..d11f639797 100644 --- a/lms/djangoapps/grades/constants.py +++ b/lms/djangoapps/grades/constants.py @@ -9,3 +9,4 @@ class ScoreDatabaseTableEnum(object): """ courseware_student_module = 'csm' submissions = 'submissions' + overrides = 'overrides' diff --git a/lms/djangoapps/grades/management/commands/tests/test_reset_grades.py b/lms/djangoapps/grades/management/commands/tests/test_reset_grades.py index be57882af6..3917944b63 100644 --- a/lms/djangoapps/grades/management/commands/tests/test_reset_grades.py +++ b/lms/djangoapps/grades/management/commands/tests/test_reset_grades.py @@ -155,7 +155,7 @@ class TestResetGrades(TestCase): self._update_or_create_grades() self._assert_grades_exist_for_courses(self.course_keys) - with self.assertNumQueries(4): + with self.assertNumQueries(7): self.command.handle(delete=True, all_courses=True) self._assert_grades_absent_for_courses(self.course_keys) @@ -174,7 +174,7 @@ class TestResetGrades(TestCase): self._update_or_create_grades() self._assert_grades_exist_for_courses(self.course_keys) - with self.assertNumQueries(4): + with self.assertNumQueries(6): self.command.handle( delete=True, courses=[unicode(course_key) for course_key in self.course_keys[:num_courses_to_reset]] @@ -199,7 +199,7 @@ class TestResetGrades(TestCase): with freeze_time(self._date_from_now(days=4)): self._update_or_create_grades(self.course_keys[:num_courses_with_updated_grades]) - with self.assertNumQueries(4): + with self.assertNumQueries(6): self.command.handle(delete=True, modified_start=self._date_str_from_now(days=2), all_courses=True) self._assert_grades_absent_for_courses(self.course_keys[:num_courses_with_updated_grades]) @@ -214,7 +214,7 @@ class TestResetGrades(TestCase): with freeze_time(self._date_from_now(days=5)): self._update_or_create_grades(self.course_keys[2:4]) - with self.assertNumQueries(4): + with self.assertNumQueries(6): self.command.handle( delete=True, modified_start=self._date_str_from_now(days=2), diff --git a/lms/djangoapps/grades/migrations/0013_persistentsubsectiongradeoverride.py b/lms/djangoapps/grades/migrations/0013_persistentsubsectiongradeoverride.py new file mode 100644 index 0000000000..ec060e366e --- /dev/null +++ b/lms/djangoapps/grades/migrations/0013_persistentsubsectiongradeoverride.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +from coursewarehistoryextended.fields import UnsignedBigIntOneToOneField + + +class Migration(migrations.Migration): + + dependencies = [ + ('grades', '0012_computegradessetting'), + ] + + operations = [ + migrations.CreateModel( + name='PersistentSubsectionGradeOverride', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('modified', models.DateTimeField(auto_now=True, db_index=True)), + ('earned_all_override', models.FloatField(null=True, blank=True)), + ('possible_all_override', models.FloatField(null=True, blank=True)), + ('earned_graded_override', models.FloatField(null=True, blank=True)), + ('possible_graded_override', models.FloatField(null=True, blank=True)), + ('grade', UnsignedBigIntOneToOneField(related_name='override', to='grades.PersistentSubsectionGrade')), + ], + ), + ] diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index ea9a7d27f7..006880c580 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -20,7 +20,7 @@ from lazy import lazy from model_utils.models import TimeStampedModel from opaque_keys.edx.keys import CourseKey, UsageKey -from coursewarehistoryextended.fields import UnsignedBigIntAutoField +from coursewarehistoryextended.fields import UnsignedBigIntAutoField, UnsignedBigIntOneToOneField from eventtracking import tracker from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField from request_cache import get_cache @@ -411,6 +411,9 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel): user_id = params.pop('user_id') usage_key = params.pop('usage_key') + # apply grade override if one exists before saving model + # EDUCTATOR-1127: remove override until this behavior is verified in production + grade, _ = cls.objects.update_or_create( user_id=user_id, course_id=usage_key.course_key, @@ -666,3 +669,24 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel): 'grading_policy_hash': unicode(grade.grading_policy_hash), } ) + + +class PersistentSubsectionGradeOverride(models.Model): + """ + A django model tracking persistent grades overrides at the subsection level. + """ + class Meta(object): + app_label = "grades" + + grade = UnsignedBigIntOneToOneField(PersistentSubsectionGrade, related_name='override') + + # Created/modified timestamps prevent race-conditions when using with async rescoring tasks + created = models.DateTimeField(auto_now_add=True, db_index=True) + modified = models.DateTimeField(auto_now=True, db_index=True) + + # earned/possible refers to the number of points achieved and available to achieve. + # graded refers to the subset of all problems that are marked as being graded. + earned_all_override = models.FloatField(null=True, blank=True) + possible_all_override = models.FloatField(null=True, blank=True) + earned_graded_override = models.FloatField(null=True, blank=True) + possible_graded_override = models.FloatField(null=True, blank=True) diff --git a/lms/djangoapps/grades/new/subsection_grade.py b/lms/djangoapps/grades/new/subsection_grade.py index 3080fbd965..13fa42149f 100644 --- a/lms/djangoapps/grades/new/subsection_grade.py +++ b/lms/djangoapps/grades/new/subsection_grade.py @@ -33,6 +33,8 @@ class SubsectionGradeBase(object): self.course_version = getattr(subsection, 'course_version', None) self.subtree_edited_timestamp = getattr(subsection, 'subtree_edited_on', None) + self.override = None + @property def attempted(self): """ @@ -137,6 +139,7 @@ class SubsectionGrade(SubsectionGradeBase): graded=False, first_attempted=model.first_attempted, ) + self.override = model.override if hasattr(model, 'override') else None self._log_event(log.debug, u"init_from_model", student) return self diff --git a/lms/djangoapps/grades/services.py b/lms/djangoapps/grades/services.py new file mode 100644 index 0000000000..53ca93fab3 --- /dev/null +++ b/lms/djangoapps/grades/services.py @@ -0,0 +1,116 @@ +from datetime import datetime + +import logging +import pytz + +from opaque_keys.edx.keys import CourseKey, UsageKey +from track.event_transaction_utils import create_new_event_transaction_id, set_event_transaction_type +from util.date_utils import to_timestamp + +from .config.waffle import waffle_flags, REJECTED_EXAM_OVERRIDES_GRADE +from .constants import ScoreDatabaseTableEnum +from .models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride +from .signals.signals import SUBSECTION_OVERRIDE_CHANGED + +log = logging.getLogger(__name__) + + +def _get_key(key_or_id, key_cls): + """ + Helper method to get a course/usage key either from a string or a key_cls, + where the key_cls (CourseKey or UsageKey) will simply be returned. + """ + return ( + key_cls.from_string(key_or_id) + if isinstance(key_or_id, basestring) + else key_or_id + ) + + +class GradesService(object): + """ + Course grade service + + Provides various functions related to getting, setting, and overriding user grades. + """ + + def get_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id): + """ + Finds and returns the earned subsection grade for user + """ + course_key = _get_key(course_key_or_id, CourseKey) + usage_key = _get_key(usage_key_or_id, UsageKey) + + return PersistentSubsectionGrade.objects.get( + user_id=user_id, + course_id=course_key, + usage_key=usage_key + ) + + def get_subsection_grade_override(self, user_id, course_key_or_id, usage_key_or_id): + """ + Finds the subsection grade for user and returns the override for that grade if it exists + + If override does not exist, returns None. If subsection grade does not exist, will raise an exception. + """ + course_key = _get_key(course_key_or_id, CourseKey) + usage_key = _get_key(usage_key_or_id, UsageKey) + + grade = self.get_subsection_grade(user_id, course_key, usage_key) + + try: + return PersistentSubsectionGradeOverride.objects.get( + grade=grade + ) + except PersistentSubsectionGradeOverride.DoesNotExist: + return 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) + + 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. + """ + course_key = _get_key(course_key_or_id, CourseKey) + usage_key = _get_key(usage_key_or_id, UsageKey) + + log.info( + u"EDUCATOR-1127: Subsection grade override for user {user_id} on subsection {usage_key} in course " + u"{course_key} would be created with params: {params}" + .format( + user_id=unicode(user_id), + usage_key=unicode(usage_key), + course_key=unicode(course_key), + params=unicode({ + 'earned_all': earned_all, + 'earned_graded': earned_graded, + }) + ) + ) + + def undo_override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id): + """ + Delete the override subsection grade row (the PersistentSubsectionGrade model must already exist) + + Fires off a recalculate_subsection_grade async task to update the PersistentSubsectionGrade table. If the + override does not exist, no error is raised, it just triggers the recalculation. + """ + course_key = _get_key(course_key_or_id, CourseKey) + usage_key = _get_key(usage_key_or_id, UsageKey) + + log.info( + u"EDUCATOR-1127: Subsection grade override for user {user_id} on subsection {usage_key} in course " + u"{course_key} would be deleted" + .format( + user_id=unicode(user_id), + usage_key=unicode(usage_key), + course_key=unicode(course_key) + ) + ) + + def should_override_grade_on_rejected_exam(self, course_key_or_id): + """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) diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py index b505001520..84bec121f3 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -30,7 +30,8 @@ from .signals import ( PROBLEM_RAW_SCORE_CHANGED, PROBLEM_WEIGHTED_SCORE_CHANGED, SCORE_PUBLISHED, - SUBSECTION_SCORE_CHANGED + SUBSECTION_SCORE_CHANGED, + SUBSECTION_OVERRIDE_CHANGED ) log = getLogger(__name__) @@ -38,6 +39,7 @@ log = getLogger(__name__) # define values to be used in grading events GRADES_RESCORE_EVENT_TYPE = 'edx.grades.problem.rescored' PROBLEM_SUBMITTED_EVENT_TYPE = 'edx.grades.problem.submitted' +SUBSECTION_OVERRIDE_EVENT_TYPE = 'edx.grades.subsection.score_overridden' @receiver(score_set) @@ -209,9 +211,10 @@ def problem_raw_score_changed_handler(sender, **kwargs): # pylint: disable=unus @receiver(PROBLEM_WEIGHTED_SCORE_CHANGED) +@receiver(SUBSECTION_OVERRIDE_CHANGED) def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argument """ - Handles the PROBLEM_WEIGHTED_SCORE_CHANGED signal by + Handles the PROBLEM_WEIGHTED_SCORE_CHANGED or SUBSECTION_OVERRIDE_CHANGED signals by enqueueing a subsection update operation to occur asynchronously. """ _emit_event(kwargs) @@ -286,3 +289,17 @@ def _emit_event(kwargs): 'event_transaction_type': unicode(root_type), } ) + + if root_type in [SUBSECTION_OVERRIDE_EVENT_TYPE]: + tracker.emit( + unicode(SUBSECTION_OVERRIDE_EVENT_TYPE), + { + 'course_id': unicode(kwargs['course_id']), + 'user_id': unicode(kwargs['user_id']), + 'problem_id': unicode(kwargs['usage_id']), + 'only_if_higher': kwargs.get('only_if_higher'), + 'override_deleted': kwargs.get('score_deleted', False), + 'event_transaction_id': unicode(get_event_transaction_id()), + 'event_transaction_type': unicode(root_type), + } + ) diff --git a/lms/djangoapps/grades/signals/signals.py b/lms/djangoapps/grades/signals/signals.py index b1c4c4f32f..a0c34b8756 100644 --- a/lms/djangoapps/grades/signals/signals.py +++ b/lms/djangoapps/grades/signals/signals.py @@ -81,3 +81,23 @@ SUBSECTION_SCORE_CHANGED = Signal( 'subsection_grade', # SubsectionGrade object ] ) + +# Signal that indicates that a user's score for a subsection has been overridden. +# This signal is generated when a user's exam attempt state is set to rejected or +# to verified from rejected. This signal may also be sent by any other client +# using the GradesService to override subsections in the future. +SUBSECTION_OVERRIDE_CHANGED = Signal( + providing_args=[ + 'user_id', # Integer User ID + 'course_id', # Unicode string representing the course + 'usage_id', # Unicode string indicating the courseware instance + 'only_if_higher', # Boolean indicating whether updates should be + # made only if the new score is higher than previous. + 'modified', # A datetime indicating when the database representation of + # this subsection override score was saved. + 'score_deleted', # Boolean indicating whether the override score was + # deleted in this event. + 'score_db_table', # The database table that houses the subsection override + # score that was created. + ] +) diff --git a/lms/djangoapps/grades/tasks.py b/lms/djangoapps/grades/tasks.py index e243f51b0d..073065068a 100644 --- a/lms/djangoapps/grades/tasks.py +++ b/lms/djangoapps/grades/tasks.py @@ -31,6 +31,7 @@ from .constants import ScoreDatabaseTableEnum from .exceptions import DatabaseNotReadyError from .new.course_grade_factory import CourseGradeFactory from .new.subsection_grade_factory import SubsectionGradeFactory +from .services import GradesService from .signals.signals import SUBSECTION_SCORE_CHANGED from .transformer import GradesTransformer @@ -206,8 +207,7 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs): score = get_score(kwargs['user_id'], scored_block_usage_key) found_modified_time = score.modified if score is not None else None - else: - assert kwargs['score_db_table'] == ScoreDatabaseTableEnum.submissions + elif kwargs['score_db_table'] == ScoreDatabaseTableEnum.submissions: score = sub_api.get_score( { "student_id": kwargs['anonymous_user_id'], @@ -217,6 +217,14 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs): } ) found_modified_time = score['created_at'] if score is not None else None + else: + assert kwargs['score_db_table'] == ScoreDatabaseTableEnum.overrides + score = GradesService().get_subsection_grade_override( + user_id=kwargs['user_id'], + course_key_or_id=kwargs['course_id'], + usage_key_or_id=kwargs['usage_id'] + ) + found_modified_time = score.modified if score is not None else None if score is None: # score should be None only if it was deleted. diff --git a/lms/djangoapps/grades/tests/test_models.py b/lms/djangoapps/grades/tests/test_models.py index 5ea10805db..9fabce19d0 100644 --- a/lms/djangoapps/grades/tests/test_models.py +++ b/lms/djangoapps/grades/tests/test_models.py @@ -23,6 +23,7 @@ from lms.djangoapps.grades.models import ( BlockRecordList, PersistentCourseGrade, PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, VisibleBlocks ) from track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type @@ -306,6 +307,15 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): grade = PersistentSubsectionGrade.create_grade(**self.params) self._assert_tracker_emitted_event(tracker_mock, grade) + def test_grade_override(self): + grade = PersistentSubsectionGrade.create_grade(**self.params) + override = PersistentSubsectionGradeOverride(grade=grade, earned_all_override=0.0, earned_graded_override=0.0) + override.save() + grade = PersistentSubsectionGrade.update_or_create_grade(**self.params) + # EDUCATOR-1127 Override is not enabled yet, change to 0.0 when enabled + self.assertEqual(grade.earned_all, 6.0) + self.assertEqual(grade.earned_graded, 6.0) + def _assert_tracker_emitted_event(self, tracker_mock, grade): """ Helper function to ensure that the mocked event tracker diff --git a/lms/djangoapps/grades/tests/test_new.py b/lms/djangoapps/grades/tests/test_new.py index 38d2be3688..cd4d405724 100644 --- a/lms/djangoapps/grades/tests/test_new.py +++ b/lms/djangoapps/grades/tests/test_new.py @@ -197,7 +197,7 @@ class TestCourseGradeFactory(GradeTestBase): self._update_grading_policy(passing=0.9) - with self.assertNumQueries(6): + with self.assertNumQueries(8): _assert_create(expected_pass=False) @ddt.data(True, False) @@ -310,7 +310,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase): mock_get_bulk_cached_grade.reset_mock() mock_create_grade.reset_mock() - with self.assertNumQueries(0): + with self.assertNumQueries(1): grade_b = self.subsection_grade_factory.create(self.sequence) self.assertTrue(mock_get_bulk_cached_grade.called) self.assertFalse(mock_create_grade.called) diff --git a/lms/djangoapps/grades/tests/test_services.py b/lms/djangoapps/grades/tests/test_services.py new file mode 100644 index 0000000000..8a172d0bef --- /dev/null +++ b/lms/djangoapps/grades/tests/test_services.py @@ -0,0 +1,206 @@ +import ddt +import pytz +from datetime import datetime +from freezegun import freeze_time +from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride +from lms.djangoapps.grades.services import GradesService, _get_key +from lms.djangoapps.grades.signals.handlers import SUBSECTION_OVERRIDE_EVENT_TYPE +from mock import patch, call +from opaque_keys.edx.keys import CourseKey, UsageKey +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from ..config.waffle import REJECTED_EXAM_OVERRIDES_GRADE +from ..constants import ScoreDatabaseTableEnum + + +class MockWaffleFlag(): + def __init__(self, state): + self.state = state + + def is_enabled(self, course_key): + return self.state + + +@ddt.ddt +class GradesServiceTests(ModuleStoreTestCase): + """ + Tests for the Grades service + """ + def setUp(self, **kwargs): + super(GradesServiceTests, self).setUp() + self.service = GradesService() + self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course') + self.subsection = ItemFactory.create(parent=self.course, category="subsection", display_name="Subsection") + self.user = UserFactory() + self.grade = PersistentSubsectionGrade.update_or_create_grade( + user_id=self.user.id, + course_id=self.course.id, + usage_key=self.subsection.location, + first_attempted=None, + visible_blocks=[], + earned_all=6.0, + possible_all=6.0, + earned_graded=5.0, + possible_graded=5.0 + ) + self.signal_patcher = patch('lms.djangoapps.grades.signals.signals.SUBSECTION_OVERRIDE_CHANGED.send') + self.mock_signal = self.signal_patcher.start() + self.id_patcher = patch('lms.djangoapps.grades.services.create_new_event_transaction_id') + self.mock_create_id = self.id_patcher.start() + self.mock_create_id.return_value = 1 + self.type_patcher = patch('lms.djangoapps.grades.services.set_event_transaction_type') + self.mock_set_type = self.type_patcher.start() + self.flag_patcher = patch('lms.djangoapps.grades.services.waffle_flags') + self.mock_waffle_flags = self.flag_patcher.start() + self.mock_waffle_flags.return_value = { + REJECTED_EXAM_OVERRIDES_GRADE: MockWaffleFlag(True) + } + + def tearDown(self): + PersistentSubsectionGradeOverride.objects.all().delete() # clear out all previous overrides + self.signal_patcher.stop() + self.id_patcher.stop() + self.type_patcher.stop() + self.flag_patcher.stop() + + def subsection_grade_to_dict(self, grade): + return { + 'earned_all': grade.earned_all, + 'earned_graded': grade.earned_graded + } + + def subsection_grade_override_to_dict(self, grade): + return { + 'earned_all_override': grade.earned_all_override, + 'earned_graded_override': grade.earned_graded_override + } + + def test_get_subsection_grade(self): + self.assertDictEqual(self.subsection_grade_to_dict(self.service.get_subsection_grade( + user_id=self.user.id, + course_key_or_id=self.course.id, + usage_key_or_id=self.subsection.location + )), { + 'earned_all': 6.0, + 'earned_graded': 5.0 + }) + + # test with id strings as parameters instead + self.assertDictEqual(self.subsection_grade_to_dict(self.service.get_subsection_grade( + user_id=self.user.id, + course_key_or_id=unicode(self.course.id), + usage_key_or_id=unicode(self.subsection.location) + )), { + 'earned_all': 6.0, + 'earned_graded': 5.0 + }) + + def test_get_subsection_grade_override(self): + override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(grade=self.grade) + + self.assertDictEqual(self.subsection_grade_override_to_dict(self.service.get_subsection_grade_override( + user_id=self.user.id, + course_key_or_id=self.course.id, + usage_key_or_id=self.subsection.location + )), { + 'earned_all_override': override.earned_all_override, + 'earned_graded_override': override.earned_graded_override + }) + + override, _ = PersistentSubsectionGradeOverride.objects.update_or_create( + grade=self.grade, + defaults={ + 'earned_all_override': 9.0 + } + ) + + # test with id strings as parameters instead + self.assertDictEqual(self.subsection_grade_override_to_dict(self.service.get_subsection_grade_override( + user_id=self.user.id, + course_key_or_id=unicode(self.course.id), + usage_key_or_id=unicode(self.subsection.location) + )), { + 'earned_all_override': override.earned_all_override, + 'earned_graded_override': override.earned_graded_override + }) + + @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): + self.service.override_subsection_grade( + user_id=self.user.id, + course_key_or_id=self.course.id, + usage_key_or_id=self.subsection.location, + earned_all=override['earned_all'], + earned_graded=override['earned_graded'] + ) + + override_obj = self.service.get_subsection_grade_override( + self.user.id, + self.course.id, + self.subsection.location + ) + self.assertIsNone(override_obj) + + @freeze_time('2017-01-01') + def test_undo_override_subsection_grade(self): + self.service.undo_override_subsection_grade( + user_id=self.user.id, + course_key_or_id=self.course.id, + usage_key_or_id=self.subsection.location, + ) + + override = self.service.get_subsection_grade_override(self.user.id, self.course.id, self.subsection.location) + self.assertIsNone(override) + + @ddt.data( + ['edX/DemoX/Demo_Course', CourseKey.from_string('edX/DemoX/Demo_Course'), CourseKey], + ['course-v1:edX+DemoX+Demo_Course', CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), CourseKey], + [CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), + CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), CourseKey], + ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow', + UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), UsageKey], + [UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), + UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), UsageKey], + ) + @ddt.unpack + def test_get_key(self, input_key, output_key, key_cls): + self.assertEqual(_get_key(input_key, key_cls), output_key) + + def test_should_override_grade_on_rejected_exam(self): + self.assertTrue(self.service.should_override_grade_on_rejected_exam('course-v1:edX+DemoX+Demo_Course')) + self.mock_waffle_flags.return_value = { + REJECTED_EXAM_OVERRIDES_GRADE: MockWaffleFlag(False) + } + self.assertFalse(self.service.should_override_grade_on_rejected_exam('course-v1:edX+DemoX+Demo_Course')) diff --git a/lms/djangoapps/grades/tests/test_tasks.py b/lms/djangoapps/grades/tests/test_tasks.py index 39556b77d2..d27bc8206d 100644 --- a/lms/djangoapps/grades/tests/test_tasks.py +++ b/lms/djangoapps/grades/tests/test_tasks.py @@ -17,6 +17,7 @@ from mock import MagicMock, patch from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum from lms.djangoapps.grades.models import PersistentCourseGrade, PersistentSubsectionGrade +from lms.djangoapps.grades.services import GradesService from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED from lms.djangoapps.grades.tasks import ( RECALCULATE_GRADE_DELAY, @@ -36,6 +37,15 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls +class MockGradesService(GradesService): + def __init__(self, mocked_return_value=None): + super(MockGradesService, self).__init__() + self.mocked_return_value = mocked_return_value + + def get_subsection_grade_override(self, user_id, course_key_or_id, usage_key_or_id): + return self.mocked_return_value + + class HasCourseWithProblemsMixin(object): """ Mixin to provide tests with a sample course with graded subsections @@ -153,10 +163,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest self.assertEquals(mock_block_structure_create.call_count, 1) @ddt.data( - (ModuleStoreEnum.Type.mongo, 1, 28, True), - (ModuleStoreEnum.Type.mongo, 1, 24, False), - (ModuleStoreEnum.Type.split, 3, 28, True), - (ModuleStoreEnum.Type.split, 3, 24, False), + (ModuleStoreEnum.Type.mongo, 1, 29, True), + (ModuleStoreEnum.Type.mongo, 1, 25, False), + (ModuleStoreEnum.Type.split, 3, 29, True), + (ModuleStoreEnum.Type.split, 3, 25, False), ) @ddt.unpack def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections): @@ -168,8 +178,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest self._apply_recalculate_subsection_grade() @ddt.data( - (ModuleStoreEnum.Type.mongo, 1, 28), - (ModuleStoreEnum.Type.split, 3, 28), + (ModuleStoreEnum.Type.mongo, 1, 29), + (ModuleStoreEnum.Type.split, 3, 29), ) @ddt.unpack def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls): @@ -229,8 +239,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0) @ddt.data( - (ModuleStoreEnum.Type.mongo, 1, 25), - (ModuleStoreEnum.Type.split, 3, 25), + (ModuleStoreEnum.Type.mongo, 1, 26), + (ModuleStoreEnum.Type.split, 3, 26), ) @ddt.unpack def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries): @@ -264,7 +274,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest self._apply_recalculate_subsection_grade() self._assert_retry_called(mock_retry) - @ddt.data(ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions) + @ddt.data(ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions, + ScoreDatabaseTableEnum.overrides) @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry') @patch('lms.djangoapps.grades.tasks.log') def test_retry_when_db_not_updated(self, score_db_table, mock_log, mock_retry): @@ -279,10 +290,16 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest self._apply_recalculate_subsection_grade( mock_score=MagicMock(module_type='any_block_type') ) - else: + elif score_db_table == ScoreDatabaseTableEnum.courseware_student_module: self._apply_recalculate_subsection_grade( mock_score=MagicMock(modified=modified_datetime) ) + else: + with patch( + 'lms.djangoapps.grades.tasks.GradesService', + return_value=MockGradesService(mocked_return_value=MagicMock(modified=modified_datetime)) + ): + recalculate_subsection_grade_v3.apply(kwargs=self.recalculate_subsection_grade_kwargs) self._assert_retry_called(mock_retry) self.assertIn( @@ -293,7 +310,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest @ddt.data( *itertools.product( (True, False), - (ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions), + (ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions, + ScoreDatabaseTableEnum.overrides), ) ) @ddt.unpack @@ -310,6 +328,11 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest self._apply_recalculate_subsection_grade( mock_score=MagicMock(module_type='any_block_type') ) + elif score_db_table == ScoreDatabaseTableEnum.overrides: + with patch('lms.djangoapps.grades.tasks.GradesService', + return_value=MockGradesService(mocked_return_value=None)) as mock_service: + mock_service.get_subsection_grade_override.return_value = None + recalculate_subsection_grade_v3.apply(kwargs=self.recalculate_subsection_grade_kwargs) else: self._apply_recalculate_subsection_grade(mock_score=None) diff --git a/lms/startup.py b/lms/startup.py index 5aae2949c6..4a928edb02 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -63,18 +63,22 @@ def run(): analytics.write_key = settings.LMS_SEGMENT_KEY # register any dependency injections that we need to support in edx_proctoring - # right now edx_proctoring is dependent on the openedx.core.djangoapps.credit + # right now edx_proctoring is dependent on the openedx.core.djangoapps.credit and + # lms.djangoapps.grades if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): # Import these here to avoid circular dependencies of the form: # edx-platform app --> DRF --> django translation --> edx-platform app from edx_proctoring.runtime import set_runtime_service from lms.djangoapps.instructor.services import InstructorService from openedx.core.djangoapps.credit.services import CreditService + from lms.djangoapps.grades.services import GradesService set_runtime_service('credit', CreditService()) # register InstructorService (for deleting student attempts and user staff access roles) set_runtime_service('instructor', InstructorService()) + set_runtime_service('grades', GradesService()) + # In order to allow modules to use a handler url, we need to # monkey-patch the x_module library. # TODO: Remove this code when Runtimes are no longer created by modulestores diff --git a/lms/static/sass/course/_profile.scss b/lms/static/sass/course/_profile.scss index f0ec9eda03..e9200e47de 100644 --- a/lms/static/sass/course/_profile.scss +++ b/lms/static/sass/course/_profile.scss @@ -294,9 +294,13 @@ p { margin: lh(0.5) 0; - color: $gray-d1;; + color: $gray-d1; font-size: em(14); font-weight: 600; + + &.override-notice { + color: $red-d1; + } } .scores { diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 34596885f4..ce71201f62 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -183,6 +183,18 @@ from django.utils.http import urlquote_plus %endif

+ <%doc> + EDUCATOR-1127: Do not display override notice until override is enabled + %if section.override is not None: +

+ %if section.format is not None and section.format == "Exam": + ${_("Exam grade has been overridden due to a failed proctoring review.")} + %else: + ${_("Section grade has been overridden.")} + %endif +

+ %endif + %if len(section.problem_scores.values()) > 0: %if section.show_grades(staff_access):
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 0922c6c963..1c9552b41e 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -95,7 +95,7 @@ git+https://github.com/edx/xblock-utils.git@v1.0.5#egg=xblock-utils==1.0.5 -e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive git+https://github.com/edx/edx-user-state-client.git@1.0.1#egg=edx-user-state-client==1.0.1 git+https://github.com/edx/xblock-lti-consumer.git@v1.1.5#egg=lti_consumer-xblock==1.1.5 -git+https://github.com/edx/edx-proctoring.git@0.19.0#egg=edx-proctoring==0.19.0 +git+https://github.com/edx/edx-proctoring.git@1.0.0#egg=edx-proctoring==1.0.0 # Third Party XBlocks git+https://github.com/open-craft/xblock-poll@v1.2.7#egg=xblock-poll==1.2.7