Commit 4e183b41 by Tyler Hallada Committed by GitHub

Merge pull request #15726 from edx/EDUCATOR-926

EDUCATOR-926 Override grade to zero when exam attempt is rejected Part 2
parents ac78d4d8 842ce836
...@@ -18,7 +18,7 @@ from django.test.client import RequestFactory ...@@ -18,7 +18,7 @@ from django.test.client import RequestFactory
from django.test.utils import override_settings from django.test.utils import override_settings
from edx_proctoring.api import create_exam, create_exam_attempt, update_attempt_status from edx_proctoring.api import create_exam, create_exam_attempt, update_attempt_status
from edx_proctoring.runtime import set_runtime_service 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 freezegun import freeze_time
from milestones.tests.utils import MilestonesTestCaseMixin from milestones.tests.utils import MilestonesTestCaseMixin
from mock import MagicMock, Mock, patch from mock import MagicMock, Mock, patch
...@@ -994,6 +994,11 @@ class TestProctoringRendering(SharedModuleStoreTestCase): ...@@ -994,6 +994,11 @@ class TestProctoringRendering(SharedModuleStoreTestCase):
MockCreditService(enrollment_mode=enrollment_mode) MockCreditService(enrollment_mode=enrollment_mode)
) )
set_runtime_service(
'grades',
MockGradesService()
)
exam_id = create_exam( exam_id = create_exam(
course_id=unicode(self.course_key), course_id=unicode(self.course_key),
content_id=unicode(sequence.location), content_id=unicode(sequence.location),
......
...@@ -3,6 +3,7 @@ Custom fields for use in the coursewarehistoryextended django app. ...@@ -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 import AutoField
from django.db.models.fields.related import OneToOneField
class UnsignedBigIntAutoField(AutoField): class UnsignedBigIntAutoField(AutoField):
...@@ -23,3 +24,31 @@ class UnsignedBigIntAutoField(AutoField): ...@@ -23,3 +24,31 @@ class UnsignedBigIntAutoField(AutoField):
return "BIGSERIAL" return "BIGSERIAL"
else: else:
return None 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
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
This module contains various configuration settings via This module contains various configuration settings via
waffle switches for the Grades app. 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 # Namespace
WAFFLE_NAMESPACE = u'grades' WAFFLE_NAMESPACE = u'grades'
...@@ -13,9 +13,27 @@ ASSUME_ZERO_GRADE_IF_ABSENT = u'assume_zero_grade_if_absent' ...@@ -13,9 +13,27 @@ ASSUME_ZERO_GRADE_IF_ABSENT = u'assume_zero_grade_if_absent'
ESTIMATE_FIRST_ATTEMPTED = u'estimate_first_attempted' ESTIMATE_FIRST_ATTEMPTED = u'estimate_first_attempted'
DISABLE_REGRADE_ON_POLICY_CHANGE = u'disable_regrade_on_policy_change' DISABLE_REGRADE_ON_POLICY_CHANGE = u'disable_regrade_on_policy_change'
# Course Flags
REJECTED_EXAM_OVERRIDES_GRADE = u'rejected_exam_overrides_grade'
def waffle(): def waffle():
""" """
Returns the namespaced, cached, audited Waffle class for Grades. Returns the namespaced, cached, audited Waffle class for Grades.
""" """
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'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
)
}
...@@ -9,3 +9,4 @@ class ScoreDatabaseTableEnum(object): ...@@ -9,3 +9,4 @@ class ScoreDatabaseTableEnum(object):
""" """
courseware_student_module = 'csm' courseware_student_module = 'csm'
submissions = 'submissions' submissions = 'submissions'
overrides = 'overrides'
...@@ -155,7 +155,7 @@ class TestResetGrades(TestCase): ...@@ -155,7 +155,7 @@ class TestResetGrades(TestCase):
self._update_or_create_grades() self._update_or_create_grades()
self._assert_grades_exist_for_courses(self.course_keys) 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.command.handle(delete=True, all_courses=True)
self._assert_grades_absent_for_courses(self.course_keys) self._assert_grades_absent_for_courses(self.course_keys)
...@@ -174,7 +174,7 @@ class TestResetGrades(TestCase): ...@@ -174,7 +174,7 @@ class TestResetGrades(TestCase):
self._update_or_create_grades() self._update_or_create_grades()
self._assert_grades_exist_for_courses(self.course_keys) self._assert_grades_exist_for_courses(self.course_keys)
with self.assertNumQueries(4): with self.assertNumQueries(6):
self.command.handle( self.command.handle(
delete=True, delete=True,
courses=[unicode(course_key) for course_key in self.course_keys[:num_courses_to_reset]] courses=[unicode(course_key) for course_key in self.course_keys[:num_courses_to_reset]]
...@@ -199,7 +199,7 @@ class TestResetGrades(TestCase): ...@@ -199,7 +199,7 @@ class TestResetGrades(TestCase):
with freeze_time(self._date_from_now(days=4)): with freeze_time(self._date_from_now(days=4)):
self._update_or_create_grades(self.course_keys[:num_courses_with_updated_grades]) 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.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]) self._assert_grades_absent_for_courses(self.course_keys[:num_courses_with_updated_grades])
...@@ -214,7 +214,7 @@ class TestResetGrades(TestCase): ...@@ -214,7 +214,7 @@ class TestResetGrades(TestCase):
with freeze_time(self._date_from_now(days=5)): with freeze_time(self._date_from_now(days=5)):
self._update_or_create_grades(self.course_keys[2:4]) self._update_or_create_grades(self.course_keys[2:4])
with self.assertNumQueries(4): with self.assertNumQueries(6):
self.command.handle( self.command.handle(
delete=True, delete=True,
modified_start=self._date_str_from_now(days=2), modified_start=self._date_str_from_now(days=2),
......
# -*- 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')),
],
),
]
...@@ -20,7 +20,7 @@ from lazy import lazy ...@@ -20,7 +20,7 @@ from lazy import lazy
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from coursewarehistoryextended.fields import UnsignedBigIntAutoField from coursewarehistoryextended.fields import UnsignedBigIntAutoField, UnsignedBigIntOneToOneField
from eventtracking import tracker from eventtracking import tracker
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField
from request_cache import get_cache from request_cache import get_cache
...@@ -411,6 +411,9 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -411,6 +411,9 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
user_id = params.pop('user_id') user_id = params.pop('user_id')
usage_key = params.pop('usage_key') 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( grade, _ = cls.objects.update_or_create(
user_id=user_id, user_id=user_id,
course_id=usage_key.course_key, course_id=usage_key.course_key,
...@@ -666,3 +669,24 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -666,3 +669,24 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel):
'grading_policy_hash': unicode(grade.grading_policy_hash), '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)
...@@ -33,6 +33,8 @@ class SubsectionGradeBase(object): ...@@ -33,6 +33,8 @@ class SubsectionGradeBase(object):
self.course_version = getattr(subsection, 'course_version', None) self.course_version = getattr(subsection, 'course_version', None)
self.subtree_edited_timestamp = getattr(subsection, 'subtree_edited_on', None) self.subtree_edited_timestamp = getattr(subsection, 'subtree_edited_on', None)
self.override = None
@property @property
def attempted(self): def attempted(self):
""" """
...@@ -137,6 +139,7 @@ class SubsectionGrade(SubsectionGradeBase): ...@@ -137,6 +139,7 @@ class SubsectionGrade(SubsectionGradeBase):
graded=False, graded=False,
first_attempted=model.first_attempted, 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) self._log_event(log.debug, u"init_from_model", student)
return self return self
......
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)
...@@ -30,7 +30,8 @@ from .signals import ( ...@@ -30,7 +30,8 @@ from .signals import (
PROBLEM_RAW_SCORE_CHANGED, PROBLEM_RAW_SCORE_CHANGED,
PROBLEM_WEIGHTED_SCORE_CHANGED, PROBLEM_WEIGHTED_SCORE_CHANGED,
SCORE_PUBLISHED, SCORE_PUBLISHED,
SUBSECTION_SCORE_CHANGED SUBSECTION_SCORE_CHANGED,
SUBSECTION_OVERRIDE_CHANGED
) )
log = getLogger(__name__) log = getLogger(__name__)
...@@ -38,6 +39,7 @@ log = getLogger(__name__) ...@@ -38,6 +39,7 @@ log = getLogger(__name__)
# define values to be used in grading events # define values to be used in grading events
GRADES_RESCORE_EVENT_TYPE = 'edx.grades.problem.rescored' GRADES_RESCORE_EVENT_TYPE = 'edx.grades.problem.rescored'
PROBLEM_SUBMITTED_EVENT_TYPE = 'edx.grades.problem.submitted' PROBLEM_SUBMITTED_EVENT_TYPE = 'edx.grades.problem.submitted'
SUBSECTION_OVERRIDE_EVENT_TYPE = 'edx.grades.subsection.score_overridden'
@receiver(score_set) @receiver(score_set)
...@@ -209,9 +211,10 @@ def problem_raw_score_changed_handler(sender, **kwargs): # pylint: disable=unus ...@@ -209,9 +211,10 @@ def problem_raw_score_changed_handler(sender, **kwargs): # pylint: disable=unus
@receiver(PROBLEM_WEIGHTED_SCORE_CHANGED) @receiver(PROBLEM_WEIGHTED_SCORE_CHANGED)
@receiver(SUBSECTION_OVERRIDE_CHANGED)
def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argument 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. enqueueing a subsection update operation to occur asynchronously.
""" """
_emit_event(kwargs) _emit_event(kwargs)
...@@ -286,3 +289,17 @@ def _emit_event(kwargs): ...@@ -286,3 +289,17 @@ def _emit_event(kwargs):
'event_transaction_type': unicode(root_type), '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),
}
)
...@@ -81,3 +81,23 @@ SUBSECTION_SCORE_CHANGED = Signal( ...@@ -81,3 +81,23 @@ SUBSECTION_SCORE_CHANGED = Signal(
'subsection_grade', # SubsectionGrade object '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.
]
)
...@@ -31,6 +31,7 @@ from .constants import ScoreDatabaseTableEnum ...@@ -31,6 +31,7 @@ from .constants import ScoreDatabaseTableEnum
from .exceptions import DatabaseNotReadyError from .exceptions import DatabaseNotReadyError
from .new.course_grade_factory import CourseGradeFactory from .new.course_grade_factory import CourseGradeFactory
from .new.subsection_grade_factory import SubsectionGradeFactory from .new.subsection_grade_factory import SubsectionGradeFactory
from .services import GradesService
from .signals.signals import SUBSECTION_SCORE_CHANGED from .signals.signals import SUBSECTION_SCORE_CHANGED
from .transformer import GradesTransformer from .transformer import GradesTransformer
...@@ -206,8 +207,7 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs): ...@@ -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) score = get_score(kwargs['user_id'], scored_block_usage_key)
found_modified_time = score.modified if score is not None else None found_modified_time = score.modified if score is not None else None
else: elif kwargs['score_db_table'] == ScoreDatabaseTableEnum.submissions:
assert kwargs['score_db_table'] == ScoreDatabaseTableEnum.submissions
score = sub_api.get_score( score = sub_api.get_score(
{ {
"student_id": kwargs['anonymous_user_id'], "student_id": kwargs['anonymous_user_id'],
...@@ -217,6 +217,14 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs): ...@@ -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 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: if score is None:
# score should be None only if it was deleted. # score should be None only if it was deleted.
......
...@@ -23,6 +23,7 @@ from lms.djangoapps.grades.models import ( ...@@ -23,6 +23,7 @@ from lms.djangoapps.grades.models import (
BlockRecordList, BlockRecordList,
PersistentCourseGrade, PersistentCourseGrade,
PersistentSubsectionGrade, PersistentSubsectionGrade,
PersistentSubsectionGradeOverride,
VisibleBlocks VisibleBlocks
) )
from track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type from track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
...@@ -306,6 +307,15 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -306,6 +307,15 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
grade = PersistentSubsectionGrade.create_grade(**self.params) grade = PersistentSubsectionGrade.create_grade(**self.params)
self._assert_tracker_emitted_event(tracker_mock, grade) 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): def _assert_tracker_emitted_event(self, tracker_mock, grade):
""" """
Helper function to ensure that the mocked event tracker Helper function to ensure that the mocked event tracker
......
...@@ -197,7 +197,7 @@ class TestCourseGradeFactory(GradeTestBase): ...@@ -197,7 +197,7 @@ class TestCourseGradeFactory(GradeTestBase):
self._update_grading_policy(passing=0.9) self._update_grading_policy(passing=0.9)
with self.assertNumQueries(6): with self.assertNumQueries(8):
_assert_create(expected_pass=False) _assert_create(expected_pass=False)
@ddt.data(True, False) @ddt.data(True, False)
...@@ -310,7 +310,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase): ...@@ -310,7 +310,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
mock_get_bulk_cached_grade.reset_mock() mock_get_bulk_cached_grade.reset_mock()
mock_create_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) grade_b = self.subsection_grade_factory.create(self.sequence)
self.assertTrue(mock_get_bulk_cached_grade.called) self.assertTrue(mock_get_bulk_cached_grade.called)
self.assertFalse(mock_create_grade.called) self.assertFalse(mock_create_grade.called)
......
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'))
...@@ -17,6 +17,7 @@ from mock import MagicMock, patch ...@@ -17,6 +17,7 @@ from mock import MagicMock, patch
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum
from lms.djangoapps.grades.models import PersistentCourseGrade, PersistentSubsectionGrade 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.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED
from lms.djangoapps.grades.tasks import ( from lms.djangoapps.grades.tasks import (
RECALCULATE_GRADE_DELAY, RECALCULATE_GRADE_DELAY,
...@@ -36,6 +37,15 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -36,6 +37,15 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls 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): class HasCourseWithProblemsMixin(object):
""" """
Mixin to provide tests with a sample course with graded subsections Mixin to provide tests with a sample course with graded subsections
...@@ -153,10 +163,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -153,10 +163,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEquals(mock_block_structure_create.call_count, 1) self.assertEquals(mock_block_structure_create.call_count, 1)
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 28, True), (ModuleStoreEnum.Type.mongo, 1, 29, True),
(ModuleStoreEnum.Type.mongo, 1, 24, False), (ModuleStoreEnum.Type.mongo, 1, 25, False),
(ModuleStoreEnum.Type.split, 3, 28, True), (ModuleStoreEnum.Type.split, 3, 29, True),
(ModuleStoreEnum.Type.split, 3, 24, False), (ModuleStoreEnum.Type.split, 3, 25, False),
) )
@ddt.unpack @ddt.unpack
def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections): def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections):
...@@ -168,8 +178,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -168,8 +178,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade() self._apply_recalculate_subsection_grade()
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 28), (ModuleStoreEnum.Type.mongo, 1, 29),
(ModuleStoreEnum.Type.split, 3, 28), (ModuleStoreEnum.Type.split, 3, 29),
) )
@ddt.unpack @ddt.unpack
def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls): 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 ...@@ -229,8 +239,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0) self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 25), (ModuleStoreEnum.Type.mongo, 1, 26),
(ModuleStoreEnum.Type.split, 3, 25), (ModuleStoreEnum.Type.split, 3, 26),
) )
@ddt.unpack @ddt.unpack
def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries): def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
...@@ -264,7 +274,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -264,7 +274,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade() self._apply_recalculate_subsection_grade()
self._assert_retry_called(mock_retry) 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.recalculate_subsection_grade_v3.retry')
@patch('lms.djangoapps.grades.tasks.log') @patch('lms.djangoapps.grades.tasks.log')
def test_retry_when_db_not_updated(self, score_db_table, mock_log, mock_retry): def test_retry_when_db_not_updated(self, score_db_table, mock_log, mock_retry):
...@@ -279,10 +290,16 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -279,10 +290,16 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade( self._apply_recalculate_subsection_grade(
mock_score=MagicMock(module_type='any_block_type') mock_score=MagicMock(module_type='any_block_type')
) )
else: elif score_db_table == ScoreDatabaseTableEnum.courseware_student_module:
self._apply_recalculate_subsection_grade( self._apply_recalculate_subsection_grade(
mock_score=MagicMock(modified=modified_datetime) 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._assert_retry_called(mock_retry)
self.assertIn( self.assertIn(
...@@ -293,7 +310,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -293,7 +310,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
@ddt.data( @ddt.data(
*itertools.product( *itertools.product(
(True, False), (True, False),
(ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions), (ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions,
ScoreDatabaseTableEnum.overrides),
) )
) )
@ddt.unpack @ddt.unpack
...@@ -310,6 +328,11 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -310,6 +328,11 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade( self._apply_recalculate_subsection_grade(
mock_score=MagicMock(module_type='any_block_type') 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: else:
self._apply_recalculate_subsection_grade(mock_score=None) self._apply_recalculate_subsection_grade(mock_score=None)
......
...@@ -63,18 +63,22 @@ def run(): ...@@ -63,18 +63,22 @@ def run():
analytics.write_key = settings.LMS_SEGMENT_KEY analytics.write_key = settings.LMS_SEGMENT_KEY
# register any dependency injections that we need to support in edx_proctoring # 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'): if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
# Import these here to avoid circular dependencies of the form: # Import these here to avoid circular dependencies of the form:
# edx-platform app --> DRF --> django translation --> edx-platform app # edx-platform app --> DRF --> django translation --> edx-platform app
from edx_proctoring.runtime import set_runtime_service from edx_proctoring.runtime import set_runtime_service
from lms.djangoapps.instructor.services import InstructorService from lms.djangoapps.instructor.services import InstructorService
from openedx.core.djangoapps.credit.services import CreditService from openedx.core.djangoapps.credit.services import CreditService
from lms.djangoapps.grades.services import GradesService
set_runtime_service('credit', CreditService()) set_runtime_service('credit', CreditService())
# register InstructorService (for deleting student attempts and user staff access roles) # register InstructorService (for deleting student attempts and user staff access roles)
set_runtime_service('instructor', InstructorService()) set_runtime_service('instructor', InstructorService())
set_runtime_service('grades', GradesService())
# In order to allow modules to use a handler url, we need to # In order to allow modules to use a handler url, we need to
# monkey-patch the x_module library. # monkey-patch the x_module library.
# TODO: Remove this code when Runtimes are no longer created by modulestores # TODO: Remove this code when Runtimes are no longer created by modulestores
......
...@@ -294,9 +294,13 @@ ...@@ -294,9 +294,13 @@
p { p {
margin: lh(0.5) 0; margin: lh(0.5) 0;
color: $gray-d1;; color: $gray-d1;
font-size: em(14); font-size: em(14);
font-weight: 600; font-weight: 600;
&.override-notice {
color: $red-d1;
}
} }
.scores { .scores {
......
...@@ -183,6 +183,18 @@ from django.utils.http import urlquote_plus ...@@ -183,6 +183,18 @@ from django.utils.http import urlquote_plus
<em class="localized-datetime" data-datetime="${section.due}" data-string="${_('due {date}')}" data-timezone="${user_timezone}" data-language="${user_language}"></em> <em class="localized-datetime" data-datetime="${section.due}" data-string="${_('due {date}')}" data-timezone="${user_timezone}" data-language="${user_language}"></em>
%endif %endif
</p> </p>
<%doc>
EDUCATOR-1127: Do not display override notice until override is enabled
%if section.override is not None:
<p class="override-notice">
%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
</p>
%endif
</%doc>
%if len(section.problem_scores.values()) > 0: %if len(section.problem_scores.values()) > 0:
%if section.show_grades(staff_access): %if section.show_grades(staff_access):
<dl class="scores"> <dl class="scores">
......
...@@ -95,7 +95,7 @@ git+https://github.com/edx/xblock-utils.git@v1.0.5#egg=xblock-utils==1.0.5 ...@@ -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 -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/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/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 # Third Party XBlocks
git+https://github.com/open-craft/xblock-poll@v1.2.7#egg=xblock-poll==1.2.7 git+https://github.com/open-craft/xblock-poll@v1.2.7#egg=xblock-poll==1.2.7
......
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