diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index b36d84794b..ca8e395753 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -534,6 +534,21 @@ class PersistentSubsectionGrade(TimeStampedModel): def _cache_key(cls, course_id): return f"subsection_grades_cache.{course_id}" + @classmethod + def clear_grade(cls, user_id, course_key): + """ + Clears Subsection grade override for a learner in a course + Arguments: + user_id: The user associated with the desired grade + course_id: The id of the course associated with the desired grade + """ + deleted_count, _ = cls.objects.filter( + user_id=user_id, + course_id=course_key, + ).delete() + cls.clear_prefetched_data(course_key) + return deleted_count + class PersistentCourseGrade(TimeStampedModel): """ @@ -681,6 +696,18 @@ class PersistentCourseGrade(TimeStampedModel): def _emit_grade_calculated_event(grade): events.course_grade_calculated(grade) + @classmethod + def clear_grade(cls, course_id, user_id): + """ + Clears course grade for a learner in a course + Arguments: + course_id: The id of the course associated with the desired grade + user_id: The user associated with the desired grade + """ + deleted_count, _ = cls.objects.filter(user_id=user_id, course_id=course_id).delete() + cls.clear_prefetched_data(course_id) + return deleted_count + @staticmethod def _emit_openedx_persistent_grade_summary_changed_event(course_id, user_id, grade): """ @@ -828,3 +855,17 @@ class PersistentSubsectionGradeOverride(models.Model): getattr(subsection_grade_model, field_name) ) return cleaned_data + + @classmethod + def clear_override(cls, user_id, course_key): + """ + Clears Subsection grade override for a learner in a course + Arguments: + user_id: The user associated with the desired grade + course_id: The id of the course associated with the desired grade + """ + total, _ = cls.objects.filter( + grade__user_id=user_id, + grade__course_id=course_key + ).delete() + return total diff --git a/lms/djangoapps/grades/models_api.py b/lms/djangoapps/grades/models_api.py index 5851c5439f..a12a534784 100644 --- a/lms/djangoapps/grades/models_api.py +++ b/lms/djangoapps/grades/models_api.py @@ -2,6 +2,7 @@ Provides Python APIs exposed from Grades models. """ +from django.db import transaction from opaque_keys.edx.keys import CourseKey, UsageKey @@ -99,3 +100,17 @@ def get_subsection_grade_override(user_id, course_key_or_id, usage_key_or_id): _ = get_subsection_grade(user_id, course_key_or_id, usage_key_or_id) return _PersistentSubsectionGradeOverride.get_override(user_id, usage_key) + + +def clear_user_course_grades(user_id, course_key): + """ + Given a user_id and course_key, clears persistent grades for a learner in a course + """ + with transaction.atomic(): + try: + _PersistentSubsectionGradeOverride.clear_override(user_id, course_key) + _PersistentSubsectionGrade.clear_grade(user_id, course_key) + _PersistentCourseGrade.clear_grade(course_key, user_id) + return 'Grades deleted Successfully' + except Exception as e: # pylint: disable=broad-except + return f'Error deleting grades: {str(e)}' diff --git a/lms/djangoapps/grades/tests/test_api.py b/lms/djangoapps/grades/tests/test_api.py index 771a0637ef..d89d301bb7 100644 --- a/lms/djangoapps/grades/tests/test_api.py +++ b/lms/djangoapps/grades/tests/test_api.py @@ -1,13 +1,17 @@ """ Tests calling the grades api directly """ -from unittest.mock import patch +from unittest.mock import patch, Mock import ddt from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.grades import api -from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride +from lms.djangoapps.grades.models import ( + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, + PersistentCourseGrade +) from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -56,7 +60,7 @@ class OverrideSubsectionGradeTests(ModuleStoreTestCase): def tearDown(self): super().tearDown() - PersistentSubsectionGradeOverride.objects.all().delete() # clear out all previous overrides + PersistentSubsectionGradeOverride.objects.all().delete() @ddt.data(0.0, None, 3.0) def test_override_subsection_grade(self, earned_graded): @@ -108,3 +112,127 @@ class OverrideSubsectionGradeTests(ModuleStoreTestCase): else: assert history_entry.history_user is None assert history_entry.history_user_id is None + + +class ClearGradeTests(ModuleStoreTestCase): + """ + Tests for the clearing grades api call + """ + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.user = UserFactory() + cls.overriding_user = UserFactory() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course', run='Spring2019') + self.subsection = BlockFactory.create(parent=self.course, category="sequential", display_name="Subsection") + 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.params = { + "user_id": self.user.id, + "course_id": self.course.id, + "course_version": "JoeMcEwing", + "percent_grade": 77.7, + "letter_grade": "Great job", + "passed": True, + } + PersistentCourseGrade.update_or_create(**self.params) + + def tearDown(self): + super().tearDown() + PersistentSubsectionGradeOverride.objects.all().delete() # clear out all previous overrides + + def test_clear_user_course_grades(self): + api.override_subsection_grade( + self.user.id, + self.course.id, + self.subsection.location, + overrider=self.overriding_user, + earned_graded=0.0, + comment='Test Override Comment', + ) + override_obj = api.get_subsection_grade_override( + self.user.id, + self.course.id, + self.subsection.location + ) + course_grade = PersistentCourseGrade.read(self.user.id, self.course.id) + self.assertIsNotNone(course_grade) + self.assertIsNotNone(override_obj) + + api.clear_user_course_grades(self.user.id, self.course.id) + with self.assertRaises(PersistentCourseGrade.DoesNotExist): + PersistentCourseGrade.read(self.user.id, self.course.id) + + with self.assertRaises(PersistentSubsectionGrade.DoesNotExist): + api.get_subsection_grade_override( + self.user.id, + self.course.id, + self.subsection.location + ) + + def test_clear_wrong_user_course_grades(self): + wrong_user = UserFactory() + api.override_subsection_grade( + self.user.id, + self.course.id, + self.subsection.location, + overrider=self.overriding_user, + earned_graded=0.0, + comment='Test Override Comment', + ) + override_obj = api.get_subsection_grade_override( + self.user.id, + self.course.id, + self.subsection.location + ) + course_grade = PersistentCourseGrade.read(self.user.id, self.course.id) + self.assertIsNotNone(course_grade) + self.assertIsNotNone(override_obj) + + api.clear_user_course_grades(wrong_user.id, self.course.id) + + after_clear_override_obj = api.get_subsection_grade_override( + self.user.id, + self.course.id, + self.subsection.location + ) + after_clear_course_grade = PersistentCourseGrade.read(self.user.id, self.course.id) + self.assertIsNotNone(after_clear_override_obj) + self.assertIsNotNone(after_clear_course_grade) + + @patch('lms.djangoapps.grades.models_api._PersistentSubsectionGrade') + @patch('lms.djangoapps.grades.models_api._PersistentCourseGrade') + @patch('lms.djangoapps.grades.models_api._PersistentSubsectionGradeOverride') + def test_assert_clear_grade_methods_called(self, mock_override, mock_course_grade, mock_subsection_grade): + api.clear_user_course_grades(self.user.id, self.course.id) + mock_override.clear_override.assert_called_with(self.user.id, self.course.id) + mock_course_grade.clear_grade.assert_called_with(self.course.id, self.user.id) + mock_subsection_grade.clear_grade.assert_called_with(self.user.id, self.course.id) + + @patch('lms.djangoapps.grades.models_api._PersistentSubsectionGrade') + @patch('lms.djangoapps.grades.models_api._PersistentCourseGrade') + def test_assert_clear_grade_exception(self, mock_course_grade, mock_subsection_grade): + with patch( + 'lms.djangoapps.grades.models_api._PersistentSubsectionGradeOverride', + Mock(side_effect=Exception) + ) as mock_override: + api.clear_user_course_grades(self.user.id, self.course.id) + self.assertRaises(Exception, mock_override) + self.assertFalse(mock_course_grade.called) + self.assertFalse(mock_subsection_grade.called) diff --git a/lms/djangoapps/grades/tests/test_models.py b/lms/djangoapps/grades/tests/test_models.py index 5405b03e94..a10d4e68f3 100644 --- a/lms/djangoapps/grades/tests/test_models.py +++ b/lms/djangoapps/grades/tests/test_models.py @@ -346,6 +346,23 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): } ) + def test_clear_subsection_grade(self): + PersistentSubsectionGrade.update_or_create_grade(**self.params) + deleted = PersistentSubsectionGrade.clear_grade(self.user.id, self.course_key) + self.assertEqual(deleted, 1) + + def test_clear_subsection_grade_override(self): + grade = PersistentSubsectionGrade.update_or_create_grade(**self.params) + PersistentSubsectionGradeOverride.update_or_create_override( + requesting_user=self.user, + subsection_grade_model=grade, + earned_all_override=0.0, + earned_graded_override=0.0, + feature=GradeOverrideFeatureEnum.gradebook, + ) + deleted = PersistentSubsectionGradeOverride.clear_override(self.user.id, self.course_key) + self.assertEqual(deleted, 1) + @ddt.ddt class PersistentCourseGradesTest(GradesModelTestCase): @@ -490,3 +507,34 @@ class PersistentCourseGradesTest(GradesModelTestCase): 'grading_policy_hash': str(grade.grading_policy_hash), } ) + + def test_clear_grade(self): + another_params = { + "user_id": 123456, + "course_id": self.course_key, + "course_version": "JoeMcEwing", + "course_edited_timestamp": datetime( + year=2016, + month=8, + day=1, + hour=18, + minute=53, + second=24, + microsecond=354741, + tzinfo=pytz.UTC, + ), + "percent_grade": 77.8, + "letter_grade": "Great job", + "passed": True, + } + + UserFactory(id=another_params['user_id']) + + PersistentCourseGrade.update_or_create(**self.params) + PersistentCourseGrade.update_or_create(**another_params) + + deleted_user_grades = PersistentCourseGrade.clear_grade(self.course_key, self.params['user_id']) + another_user_grade = PersistentCourseGrade.read(another_params['user_id'], self.course_key) + + self.assertEqual(deleted_user_grades, 1) + self.assertIsNotNone(another_user_grade) diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index a1edf20198..11726120f8 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -560,6 +560,7 @@ CSRF_TRUSTED_ORIGINS = [ 'http://localhost:2002', # frontend-app-discussions 'http://localhost:1991', # frontend-app-admin-portal 'http://localhost:1999', # frontend-app-authn + 'http://localhost:18450', # frontend-app-support-tools ]