diff --git a/lms/djangoapps/grades/api.py b/lms/djangoapps/grades/api.py index bbf2d93a5f..5040d860be 100644 --- a/lms/djangoapps/grades/api.py +++ b/lms/djangoapps/grades/api.py @@ -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) diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index 50e9e455b2..1b1b2fe16e 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -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): """ diff --git a/lms/djangoapps/grades/models_api.py b/lms/djangoapps/grades/models_api.py index 61427c15af..8ab7b33f70 100644 --- a/lms/djangoapps/grades/models_api.py +++ b/lms/djangoapps/grades/models_api.py @@ -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) diff --git a/lms/djangoapps/grades/services.py b/lms/djangoapps/grades/services.py index e42a3b3719..a7d966475f 100644 --- a/lms/djangoapps/grades/services.py +++ b/lms/djangoapps/grades/services.py @@ -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) diff --git a/lms/djangoapps/grades/tasks.py b/lms/djangoapps/grades/tasks.py index 9329fd37c0..cd5d43f189 100644 --- a/lms/djangoapps/grades/tasks.py +++ b/lms/djangoapps/grades/tasks.py @@ -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'] diff --git a/lms/djangoapps/grades/tests/test_services.py b/lms/djangoapps/grades/tests/test_services.py index fdec8d49c3..846172ebdf 100644 --- a/lms/djangoapps/grades/tests/test_services.py +++ b/lms/djangoapps/grades/tests/test_services.py @@ -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) diff --git a/lms/djangoapps/grades/tests/test_tasks.py b/lms/djangoapps/grades/tests/test_tasks.py index 8c64d89a17..1ce0f263d9 100644 --- a/lms/djangoapps/grades/tests/test_tasks.py +++ b/lms/djangoapps/grades/tests/test_tasks.py @@ -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) diff --git a/openedx/core/djangoapps/course_groups/cohorts.py b/openedx/core/djangoapps/course_groups/cohorts.py index cc938104db..f4d0245b45 100644 --- a/openedx/core/djangoapps/course_groups/cohorts.py +++ b/openedx/core/djangoapps/course_groups/cohorts.py @@ -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)