feat: api function to delete learner's course grades

This commit is contained in:
hajorg
2024-03-22 10:32:54 +01:00
parent 09a8cfcccf
commit 36f3895eaf
5 changed files with 236 additions and 3 deletions

View File

@@ -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

View File

@@ -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)}'

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
]