Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: api function to delete learner's course grades #34410

Merged
merged 3 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 grade override for a learner in a course
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Clears Subsection grade override for a learner in a course
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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

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)
14 changes: 14 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,16 @@ 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:
_PersistentSubsectionGrade.delete_subsection_grades_for_learner(user_id, course_key)
_PersistentCourseGrade.delete_course_grade_for_learner(course_key, user_id)
return 'Grades deleted Successfully'
except Exception as e: # pylint: disable=broad-except
return f'Error deleting grades: {str(e)}'
133 changes: 130 additions & 3 deletions lms/djangoapps/grades/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,126 @@ 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(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')
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)

@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)
53 changes: 53 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,25 @@ 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)

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 +509,37 @@ def _assert_tracker_emitted_event(self, tracker_mock, grade):
'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)

PersistentCourseGrade.delete_course_grade_for_learner(
self.course_key, self.params['user_id']
)
with self.assertRaises(PersistentCourseGrade.DoesNotExist):
PersistentCourseGrade.read(self.params['user_id'], self.course_key)

another_user_grade = PersistentCourseGrade.read(another_params['user_id'], self.course_key)
self.assertIsNotNone(another_user_grade)
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
Loading