Merge pull request #20719 from edx/dcs/grade-support

API support for bulk grade import/export
This commit is contained in:
Dave St.Germain
2019-06-05 14:46:15 -04:00
committed by GitHub
8 changed files with 216 additions and 137 deletions

View File

@@ -2,9 +2,17 @@
"""
Python APIs exposed by the grades app to other in-process apps.
"""
from __future__ import absolute_import, unicode_literals
from datetime import datetime
import pytz
from six import text_type
from django.core.exceptions import ObjectDoesNotExist
# Public Grades Factories
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
from lms.djangoapps.grades.subsection_grade import CreateSubsectionGrade
from lms.djangoapps.grades.subsection_grade_factory import SubsectionGradeFactory
# Public Grades Functions
@@ -12,7 +20,7 @@ from lms.djangoapps.grades.models_api import *
from lms.djangoapps.grades.tasks import compute_all_grades_for_course as task_compute_all_grades_for_course
# Public Grades Modules
from lms.djangoapps.grades import events, constants, context
from lms.djangoapps.grades import events, constants, context, course_data
from lms.djangoapps.grades.signals import signals
from lms.djangoapps.grades.util_services import GradesUtilService
@@ -22,3 +30,125 @@ from lms.djangoapps.grades.signals.handlers import disconnect_submissions_signal
# Grades APIs that should NOT belong within the Grades subsystem
# TODO move Gradebook to be an external feature outside of core Grades
from lms.djangoapps.grades.config.waffle import is_writable_gradebook_enabled
from lms.djangoapps.utils import _get_key
from opaque_keys.edx.keys import CourseKey, UsageKey
from track.event_transaction_utils import create_new_event_transaction_id, set_event_transaction_type
def graded_subsections_for_course_id(course_id):
"""
Return graded subsections for the course.
"""
from lms.djangoapps.grades.context import graded_subsections_for_course
return graded_subsections_for_course(course_data.CourseData(user=None, course_key=course_id).collected_structure)
def override_subsection_grade(
user_id, course_key_or_id, usage_key_or_id, overrider=None, earned_all=None, earned_graded=None,
feature=constants.GradeOverrideFeatureEnum.proctoring
):
"""
Creates a PersistentSubsectionGradeOverride corresponding to the given
user, course, and usage_key.
Will also create a ``PersistentSubsectionGrade`` for this (user, course, usage_key)
if none currently exists.
Fires off a recalculate_subsection_grade async task to update the PersistentCourseGrade table.
Will not override ``earned_all`` or ``earned_graded`` value if they are ``None``.
Both of these parameters have ``None`` as their default value.
"""
course_key = _get_key(course_key_or_id, CourseKey)
usage_key = _get_key(usage_key_or_id, UsageKey)
try:
grade = get_subsection_grade(user_id, usage_key.course_key, usage_key)
except ObjectDoesNotExist:
grade = _create_subsection_grade(user_id, course_key, usage_key)
override = update_or_create_override(
grade,
requesting_user=overrider,
subsection_grade_model=grade,
feature=feature,
earned_all_override=earned_all,
earned_graded_override=earned_graded,
)
# Cache a new event id and event type which the signal handler will use to emit a tracking log event.
create_new_event_transaction_id()
set_event_transaction_type(events.SUBSECTION_OVERRIDE_EVENT_TYPE)
# This will eventually trigger a re-computation of the course grade,
# taking the new PersistentSubsectionGradeOverride into account.
signals.SUBSECTION_OVERRIDE_CHANGED.send(
sender=None,
user_id=user_id,
course_id=text_type(course_key),
usage_id=text_type(usage_key),
only_if_higher=False,
modified=override.modified,
score_deleted=False,
score_db_table=constants.ScoreDatabaseTableEnum.overrides
)
def undo_override_subsection_grade(user_id, course_key_or_id, usage_key_or_id, feature=''):
"""
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)
try:
override = get_subsection_grade_override(user_id, course_key, usage_key)
except ObjectDoesNotExist:
return
# Older rejected exam attempts that transition to verified might not have an override created
if override is not None:
override.delete(feature=feature)
# Cache a new event id and event type which the signal handler will use to emit a tracking log event.
create_new_event_transaction_id()
set_event_transaction_type(events.SUBSECTION_OVERRIDE_EVENT_TYPE)
# Signal will trigger subsection recalculation which will call PersistentSubsectionGrade.update_or_create_grade
# which will no longer use the above deleted override, and instead return the grade to the original score from
# the actual problem responses before writing to the table.
signals.SUBSECTION_OVERRIDE_CHANGED.send(
sender=None,
user_id=user_id,
course_id=text_type(course_key),
usage_id=text_type(usage_key),
only_if_higher=False,
modified=datetime.now().replace(tzinfo=pytz.UTC), # Not used when score_deleted=True
score_deleted=True,
score_db_table=constants.ScoreDatabaseTableEnum.overrides
)
def should_override_grade_on_rejected_exam(course_key_or_id):
"""Convienence function to return the state of the CourseWaffleFlag REJECTED_EXAM_OVERRIDES_GRADE"""
from .config.waffle import waffle_flags, REJECTED_EXAM_OVERRIDES_GRADE
course_key = _get_key(course_key_or_id, CourseKey)
return waffle_flags()[REJECTED_EXAM_OVERRIDES_GRADE].is_enabled(course_key)
def _create_subsection_grade(user_id, course_key, usage_key):
"""
Given a user_id, course_key, and subsection usage_key,
creates a new ``PersistentSubsectionGrade``.
"""
from lms.djangoapps.courseware.courses import get_course
from django.contrib.auth import get_user_model
course = get_course(course_key, depth=None)
subsection = course.get_child(usage_key)
if not subsection:
raise Exception('Subsection with given usage_key does not exist.')
user = get_user_model().objects.get(id=user_id)
subsection_grade = CreateSubsectionGrade(subsection, course_data.CourseData(user, course=course).structure, {}, {})
return subsection_grade.update_or_create_model(user, force_update_subsections=True)

View File

@@ -736,6 +736,15 @@ class PersistentSubsectionGradeOverride(models.Model):
)
return cleaned_data
def delete(self, **kwargs): # pylint: disable=arguments-differ
# TODO: a proper history table
PersistentSubsectionGradeOverrideHistory.objects.create(
override_id=self.id,
feature=kwargs.pop('feature', ''),
action=PersistentSubsectionGradeOverrideHistory.DELETE
)
super(PersistentSubsectionGradeOverride, self).delete(**kwargs)
class PersistentSubsectionGradeOverrideHistory(models.Model):
"""

View File

@@ -7,6 +7,9 @@ from lms.djangoapps.grades.models import (
PersistentSubsectionGradeOverride as _PersistentSubsectionGradeOverride,
VisibleBlocks as _VisibleBlocks,
)
from lms.djangoapps.utils import _get_key
from opaque_keys.edx.keys import CourseKey, UsageKey
def prefetch_grade_overrides_and_visible_blocks(user, course_key):
@@ -46,3 +49,51 @@ def get_recently_modified_grades(course_keys, start_date, end_date):
grade_filter_args['modified__lte'] = end_date
return _PersistentCourseGrade.objects.filter(**grade_filter_args).order_by('modified')
def update_or_create_override(grade, **kwargs):
"""
Update or creates a subsection override.
"""
kwargs['subsection_grade_model'] = grade
return _PersistentSubsectionGradeOverride.update_or_create_override(**kwargs)
def get_subsection_grade(user_id, course_key_or_id, usage_key_or_id):
"""
Find and return 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_grades(user_id, course_key_or_id):
"""
Return dictionary of grades for user_id.
"""
course_key = _get_key(course_key_or_id, CourseKey)
grades = {}
for grade in _PersistentSubsectionGrade.bulk_read_grades(user_id, course_key):
grades[grade.usage_key] = grade
return grades
def get_subsection_grade_override(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.
"""
usage_key = _get_key(usage_key_or_id, UsageKey)
# Verify that a corresponding subsection grade exists for the given user and usage_key
# Raises PersistentSubsectionGrade.DoesNotExist if it does not exist.
_ = get_subsection_grade(user_id, course_key_or_id, usage_key_or_id)
return _PersistentSubsectionGradeOverride.get_override(user_id, usage_key)

View File

@@ -1,30 +1,8 @@
"""
Grade service
"""
from datetime import datetime
from django.contrib.auth import get_user_model
import pytz
from six import text_type
from lms.djangoapps.grades.course_data import CourseData
from lms.djangoapps.grades.subsection_grade import CreateSubsectionGrade
from lms.djangoapps.utils import _get_key
from opaque_keys.edx.keys import CourseKey, UsageKey
from track.event_transaction_utils import create_new_event_transaction_id, set_event_transaction_type
from .config.waffle import waffle_flags, REJECTED_EXAM_OVERRIDES_GRADE
from .constants import ScoreDatabaseTableEnum, GradeOverrideFeatureEnum
from .events import SUBSECTION_OVERRIDE_EVENT_TYPE
from .models import (
PersistentSubsectionGrade,
PersistentSubsectionGradeOverride,
PersistentSubsectionGradeOverrideHistory
)
from .signals.signals import SUBSECTION_OVERRIDE_CHANGED
USER_MODEL = get_user_model()
from __future__ import absolute_import
from . import api
class GradesService(object):
@@ -38,14 +16,7 @@ class GradesService(object):
"""
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
)
return api.get_subsection_grade(user_id, course_key_or_id, usage_key_or_id)
def get_subsection_grade_override(self, user_id, course_key_or_id, usage_key_or_id):
"""
@@ -53,16 +24,11 @@ class GradesService(object):
If override does not exist, returns None. If subsection grade does not exist, will raise an exception.
"""
usage_key = _get_key(usage_key_or_id, UsageKey)
# Verify that a corresponding subsection grade exists for the given user and usage_key
# Raises PersistentSubsectionGrade.DoesNotExist if it does not exist.
_ = self.get_subsection_grade(user_id, course_key_or_id, usage_key_or_id)
return PersistentSubsectionGradeOverride.get_override(user_id, usage_key)
return api.get_subsection_grade_override(user_id, course_key_or_id, usage_key_or_id)
def override_subsection_grade(
self, user_id, course_key_or_id, usage_key_or_id, earned_all=None, earned_graded=None
self, user_id, course_key_or_id, usage_key_or_id, earned_all=None, earned_graded=None,
feature=api.constants.GradeOverrideFeatureEnum.proctoring
):
"""
Creates a PersistentSubsectionGradeOverride corresponding to the given
@@ -74,100 +40,23 @@ class GradesService(object):
Will not override ``earned_all`` or ``earned_graded`` value if they are ``None``.
Both of these parameters have ``None`` as their default value.
"""
course_key = _get_key(course_key_or_id, CourseKey)
usage_key = _get_key(usage_key_or_id, UsageKey)
return api.override_subsection_grade(user_id,
course_key_or_id,
usage_key_or_id,
earned_all=earned_all,
earned_graded=earned_graded,
feature=feature)
try:
grade = PersistentSubsectionGrade.read_grade(
user_id=user_id,
usage_key=usage_key
)
except PersistentSubsectionGrade.DoesNotExist:
grade = self._create_subsection_grade(user_id, course_key, usage_key)
override = PersistentSubsectionGradeOverride.update_or_create_override(
requesting_user=None,
subsection_grade_model=grade,
feature=GradeOverrideFeatureEnum.proctoring,
earned_all_override=earned_all,
earned_graded_override=earned_graded,
)
# Cache a new event id and event type which the signal handler will use to emit a tracking log event.
create_new_event_transaction_id()
set_event_transaction_type(SUBSECTION_OVERRIDE_EVENT_TYPE)
# This will eventually trigger a re-computation of the course grade,
# taking the new PersistentSubsectionGradeOverride into account.
SUBSECTION_OVERRIDE_CHANGED.send(
sender=None,
user_id=user_id,
course_id=text_type(course_key),
usage_id=text_type(usage_key),
only_if_higher=False,
modified=override.modified,
score_deleted=False,
score_db_table=ScoreDatabaseTableEnum.overrides
)
def undo_override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id):
def undo_override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id,
feature=api.constants.GradeOverrideFeatureEnum.proctoring):
"""
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)
try:
override = self.get_subsection_grade_override(user_id, course_key, usage_key)
except PersistentSubsectionGrade.DoesNotExist:
return
# Older rejected exam attempts that transition to verified might not have an override created
if override is not None:
_ = PersistentSubsectionGradeOverrideHistory.objects.create(
override_id=override.id,
feature=GradeOverrideFeatureEnum.proctoring,
action=PersistentSubsectionGradeOverrideHistory.DELETE
)
override.delete()
# Cache a new event id and event type which the signal handler will use to emit a tracking log event.
create_new_event_transaction_id()
set_event_transaction_type(SUBSECTION_OVERRIDE_EVENT_TYPE)
# Signal will trigger subsection recalculation which will call PersistentSubsectionGrade.update_or_create_grade
# which will no longer use the above deleted override, and instead return the grade to the original score from
# the actual problem responses before writing to the table.
SUBSECTION_OVERRIDE_CHANGED.send(
sender=None,
user_id=user_id,
course_id=text_type(course_key),
usage_id=text_type(usage_key),
only_if_higher=False,
modified=datetime.now().replace(tzinfo=pytz.UTC), # Not used when score_deleted=True
score_deleted=True,
score_db_table=ScoreDatabaseTableEnum.overrides
)
return api.undo_override_subsection_grade(user_id, course_key_or_id, usage_key_or_id, feature=feature)
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)
def _create_subsection_grade(self, user_id, course_key, usage_key):
"""
Given a user_id, course_key, and subsection usage_key,
creates a new ``PersistentSubsectionGrade``.
"""
from lms.djangoapps.courseware.courses import get_course
course = get_course(course_key, depth=None)
subsection = course.get_child(usage_key)
if not subsection:
raise Exception('Subsection with given usage_key does not exist.')
user = USER_MODEL.objects.get(id=user_id)
course_data = CourseData(user, course=course)
subsection_grade = CreateSubsectionGrade(subsection, course_data.structure, {}, {})
return subsection_grade.update_or_create_model(user, force_update_subsections=True)
return api.should_override_grade_on_rejected_exam(course_key_or_id)

View File

@@ -29,7 +29,6 @@ from .config.waffle import DISABLE_REGRADE_ON_POLICY_CHANGE, waffle
from .constants import ScoreDatabaseTableEnum
from .course_grade_factory import CourseGradeFactory
from .exceptions import DatabaseNotReadyError
from .services import GradesService
from .signals.signals import SUBSECTION_SCORE_CHANGED
from .subsection_grade_factory import SubsectionGradeFactory
from .transformer import GradesTransformer
@@ -270,7 +269,8 @@ 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(
from . import api
score = api.get_subsection_grade_override(
user_id=kwargs['user_id'],
course_key_or_id=kwargs['course_id'],
usage_key_or_id=kwargs['usage_id']

View File

@@ -68,12 +68,12 @@ class GradesServiceTests(ModuleStoreTestCase):
)
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.id_patcher = patch('lms.djangoapps.grades.api.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.type_patcher = patch('lms.djangoapps.grades.api.set_event_transaction_type')
self.mock_set_type = self.type_patcher.start()
self.flag_patcher = patch('lms.djangoapps.grades.services.waffle_flags')
self.flag_patcher = patch('lms.djangoapps.grades.config.waffle.waffle_flags')
self.mock_waffle_flags = self.flag_patcher.start()
self.mock_waffle_flags.return_value = {
REJECTED_EXAM_OVERRIDES_GRADE: MockWaffleFlag(True)

View File

@@ -310,7 +310,7 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
)
else:
with patch(
'lms.djangoapps.grades.tasks.GradesService',
'lms.djangoapps.grades.api',
return_value=MockGradesService(mocked_return_value=MagicMock(modified=modified_datetime))
):
recalculate_subsection_grade_v3.apply(kwargs=self.recalculate_subsection_grade_kwargs)
@@ -343,7 +343,7 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
mock_score=MagicMock(module_type='any_block_type')
)
elif score_db_table == ScoreDatabaseTableEnum.overrides:
with patch('lms.djangoapps.grades.tasks.GradesService',
with patch('lms.djangoapps.grades.api',
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)
@@ -653,7 +653,7 @@ class FreezeGradingAfterCourseEndTest(HasCourseWithProblemsMixin, ModuleStoreTes
with override_waffle_flag(self.freeze_grade_flag, active=freeze_flag_value):
modified_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=1)
with patch(
'lms.djangoapps.grades.tasks.GradesService',
'lms.djangoapps.grades.api',
return_value=MockGradesService(mocked_return_value=MagicMock(modified=modified_datetime))
) as mock_grade_service:
result = recalculate_subsection_grade_v3.apply_async(kwargs=self.recalculate_subsection_grade_kwargs)

View File

@@ -219,7 +219,7 @@ def get_cohort(user, course_key, assign=True, use_cached=False):
Raises:
ValueError if the CourseKey doesn't exist.
"""
if user.is_anonymous:
if user is None or user.is_anonymous:
return None
cache = RequestCache(COHORT_CACHE_NAMESPACE).data
cache_key = _cohort_cache_key(user.id, course_key)