diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index b36d84794b..a5608eb39a 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -534,6 +534,27 @@ class PersistentSubsectionGrade(TimeStampedModel): def _cache_key(cls, course_id): return f"subsection_grades_cache.{course_id}" + @classmethod + def delete_subsection_grades_for_learner(cls, user_id, course_key): + """ + Clears Subsection grades and overrides 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 + """ + try: + deleted_count, deleted_obj = cls.objects.filter( + user_id=user_id, + course_id=course_key, + ).delete() + get_cache(cls._CACHE_NAMESPACE)[cls._cache_key(course_key)].pop(user_id) + if deleted_obj['grades.PersistentSubsectionGradeOverride'] is not None: + PersistentSubsectionGradeOverride.clear_prefetched_overrides_for_learner(user_id, course_key) + except KeyError: + pass + + return deleted_count + class PersistentCourseGrade(TimeStampedModel): """ @@ -681,6 +702,20 @@ class PersistentCourseGrade(TimeStampedModel): def _emit_grade_calculated_event(grade): events.course_grade_calculated(grade) + @classmethod + def delete_course_grade_for_learner(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 + """ + try: + cls.objects.get(user_id=user_id, course_id=course_id).delete() + get_cache(cls._CACHE_NAMESPACE)[cls._cache_key(course_id)].pop(user_id) + except (PersistentCourseGrade.DoesNotExist, KeyError): + pass + @staticmethod def _emit_openedx_persistent_grade_summary_changed_event(course_id, user_id, grade): """ @@ -828,3 +863,7 @@ class PersistentSubsectionGradeOverride(models.Model): getattr(subsection_grade_model, field_name) ) return cleaned_data + + @classmethod + def clear_prefetched_overrides_for_learner(cls, user_id, course_key): + get_cache(cls._CACHE_NAMESPACE).pop((user_id, str(course_key)), None) diff --git a/lms/djangoapps/grades/models_api.py b/lms/djangoapps/grades/models_api.py index 5851c5439f..0466fb2a56 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,12 @@ 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(): + _PersistentSubsectionGrade.delete_subsection_grades_for_learner(user_id, course_key) + _PersistentCourseGrade.delete_course_grade_for_learner(course_key, user_id) diff --git a/lms/djangoapps/grades/tests/test_api.py b/lms/djangoapps/grades/tests/test_api.py index 771a0637ef..e42e9ea56f 100644 --- a/lms/djangoapps/grades/tests/test_api.py +++ b/lms/djangoapps/grades/tests/test_api.py @@ -7,7 +7,11 @@ 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 @@ -40,7 +44,7 @@ class OverrideSubsectionGradeTests(ModuleStoreTestCase): def setUp(self): super().setUp() - self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course', run='Spring2019') + self.course = CourseFactory.create() self.subsection = BlockFactory.create(parent=self.course, category="sequential", display_name="Subsection") self.grade = PersistentSubsectionGrade.update_or_create_grade( user_id=self.user.id, @@ -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,131 @@ 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() + self.subsection = BlockFactory.create(parent=self.course) + 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 _create_and_get_user_grades(self, user_id): + """ Creates grades for a user and override object """ + api.override_subsection_grade( + user_id, + self.course.id, + self.subsection.location, + overrider=self.overriding_user, + earned_graded=0.0, + comment='Test Override Comment', + ) + return api.get_subsection_grade_override( + user_id, + self.course.id, + self.subsection.location + ) + + def test_clear_other_user_course_grades(self): + """ + Make sure it deletes grades for other_user and not self.user + """ + # Create grades for 2 users + other_user = UserFactory() + user_override_obj = self._create_and_get_user_grades(self.user.id) + other_user_override_obj = self._create_and_get_user_grades(other_user.id) + + # fetch and assert grades are available for both users + user_course_grade = PersistentCourseGrade.read(self.user.id, self.course.id) + other_user_course_grade = PersistentCourseGrade.read(self.user.id, self.course.id) + self.assertIsNotNone(user_course_grade) + self.assertIsNotNone(user_override_obj) + self.assertIsNotNone(other_user_override_obj) + self.assertIsNotNone(other_user_course_grade) + + api.clear_user_course_grades(other_user.id, self.course.id) + + # assert grades after deletion for other_user + after_clear_override_obj = api.get_subsection_grade_override( + self.user.id, + self.course.id, + self.subsection.location + ) + after_clear_user_course_grade = PersistentCourseGrade.read(self.user.id, self.course.id) + with self.assertRaises(PersistentCourseGrade.DoesNotExist): + PersistentCourseGrade.read(other_user.id, self.course.id) + self.assertIsNotNone(after_clear_override_obj) + self.assertIsNotNone(after_clear_user_course_grade) + + @patch('lms.djangoapps.grades.models_api._PersistentSubsectionGrade') + @patch('lms.djangoapps.grades.models_api._PersistentCourseGrade') + def test_assert_clear_grade_methods_called(self, mock_course_grade, mock_subsection_grade): + api.clear_user_course_grades(self.user.id, self.course.id) + mock_course_grade.delete_course_grade_for_learner.assert_called_with(self.course.id, self.user.id) + mock_subsection_grade.delete_subsection_grades_for_learner.assert_called_with(self.user.id, self.course.id) diff --git a/lms/djangoapps/grades/tests/test_models.py b/lms/djangoapps/grades/tests/test_models.py index 5405b03e94..acdb20c5f1 100644 --- a/lms/djangoapps/grades/tests/test_models.py +++ b/lms/djangoapps/grades/tests/test_models.py @@ -346,6 +346,28 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): } ) + def test_clear_subsection_grade(self): + PersistentSubsectionGrade.update_or_create_grade(**self.params) + deleted = PersistentSubsectionGrade.delete_subsection_grades_for_learner( + self.user.id, self.course_key + ) + self.assertEqual(deleted, 1) + self.assertFalse(PersistentSubsectionGrade.objects.filter( + user_id=self.user.id, course_id=self.course_key).exists() + ) + + 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 = PersistentSubsectionGrade.delete_subsection_grades_for_learner(self.user.id, self.course_key) + self.assertEqual(deleted, 2) + @ddt.ddt class PersistentCourseGradesTest(GradesModelTestCase): @@ -490,3 +512,41 @@ class PersistentCourseGradesTest(GradesModelTestCase): 'grading_policy_hash': str(grade.grading_policy_hash), } ) + + def test_clear_course_grade(self): + # create params for another user and another course + other_user = UserFactory.create() + other_user_params = { + **self.params, + 'user_id': other_user.id + } + + other_course_key = CourseLocator( + org='some_org', + course='some_other_course', + run='some_run' + ) + user_other_course_params = { + **self.params, + 'course_id': other_course_key + } + + # create course grades based on different params + PersistentCourseGrade.update_or_create(**self.params) + PersistentCourseGrade.update_or_create(**other_user_params) + PersistentCourseGrade.update_or_create(**user_other_course_params) + + PersistentCourseGrade.delete_course_grade_for_learner( + self.course_key, self.params['user_id'] + ) + + # assert after deleteing grade for a single user and course + with self.assertRaises(PersistentCourseGrade.DoesNotExist): + PersistentCourseGrade.read(self.params['user_id'], self.course_key) + + another_user_grade = PersistentCourseGrade.read(other_user_params['user_id'], self.course_key) + self.assertIsNotNone(another_user_grade) + + self.assertTrue(PersistentCourseGrade.objects.filter( + user_id=self.params['user_id'], course_id=other_course_key).exists() + ) 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 ]