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
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),
......
......@@ -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
......@@ -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
)
}
......@@ -9,3 +9,4 @@ class ScoreDatabaseTableEnum(object):
"""
courseware_student_module = 'csm'
submissions = 'submissions'
overrides = 'overrides'
......@@ -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),
......
# -*- 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
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)
......@@ -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
......
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 (
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),
}
)
......@@ -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.
]
)
......@@ -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.
......
......@@ -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
......
......@@ -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)
......
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
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)
......
......@@ -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
......
......@@ -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 {
......
......@@ -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>
%endif
</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 section.show_grades(staff_access):
<dl class="scores">
......
......@@ -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
......
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