Skip to content

Commit

Permalink
Merge pull request #34410 from openedx/hajorg/au-1912-delete-learner-…
Browse files Browse the repository at this point in the history
…course-grade

feat: api function to delete learner's course grades
  • Loading branch information
hajorg authored Mar 26, 2024
2 parents ce1064b + 1c3f403 commit 53da867
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 3 deletions.
39 changes: 39 additions & 0 deletions lms/djangoapps/grades/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,27 @@ def _emit_grade_calculated_event(grade):
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):
"""
Expand Down Expand Up @@ -681,6 +702,20 @@ def _cache_key(cls, course_id):
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):
"""
Expand Down Expand Up @@ -828,3 +863,7 @@ def _prepare_override_params(subsection_grade_model, override_data):
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)
10 changes: 10 additions & 0 deletions lms/djangoapps/grades/models_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Provides Python APIs exposed from Grades models.
"""

from django.db import transaction

from opaque_keys.edx.keys import CourseKey, UsageKey

Expand Down Expand Up @@ -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)
138 changes: 135 additions & 3 deletions lms/djangoapps/grades/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@

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

Expand Down Expand Up @@ -40,7 +44,7 @@ def tearDownClass(cls):

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,
Expand All @@ -56,7 +60,7 @@ def setUp(self):

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):
Expand Down Expand Up @@ -108,3 +112,131 @@ def test_override_subsection_grade(self, earned_graded):
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)
60 changes: 60 additions & 0 deletions lms/djangoapps/grades/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,28 @@ def _assert_tracker_emitted_event(self, tracker_mock, grade):
}
)

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):
Expand Down Expand Up @@ -490,3 +512,41 @@ def _assert_tracker_emitted_event(self, tracker_mock, grade):
'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()
)
1 change: 1 addition & 0 deletions lms/envs/devstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing
'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
]


Expand Down

0 comments on commit 53da867

Please sign in to comment.