Merge pull request #15726 from edx/EDUCATOR-926
EDUCATOR-926 Override grade to zero when exam attempt is rejected Part 2
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
116
lms/djangoapps/grades/services.py
Normal file
116
lms/djangoapps/grades/services.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
206
lms/djangoapps/grades/tests/test_services.py
Normal file
206
lms/djangoapps/grades/tests/test_services.py
Normal file
@@ -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'))
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user