diff --git a/lms/djangoapps/support/tasks.py b/lms/djangoapps/support/tasks.py new file mode 100644 index 0000000000..fc33446887 --- /dev/null +++ b/lms/djangoapps/support/tasks.py @@ -0,0 +1,67 @@ +""" Celery Tasks for the Instructor App. """ + +from datetime import datetime +import logging +from celery import shared_task +from edx_django_utils.monitoring import set_code_owner_attribute + +from common.djangoapps.student.models.course_enrollment import CourseEnrollment +from common.djangoapps.student.models.user import get_user_by_username_or_email +from lms.djangoapps.courseware.courses import get_course +from lms.djangoapps.courseware.models import StudentModule +from lms.djangoapps.instructor.enrollment import reset_student_attempts +from lms.djangoapps.support.models import CourseResetAudit + +log = logging.getLogger(__name__) + + +def update_audit_status(audit_instance, status): + audit_instance.status = status + if status == CourseResetAudit.CourseResetStatus.COMPLETE: + audit_instance.completed_at = datetime.now() + audit_instance.save() + + +def get_blocks(course): + """ Get a list of problem xblock for the course.""" + blocks = [] + for section in course.get_children(): + for subsection in section.get_children(): + for vertical in subsection.get_children(): + for block in vertical.get_children(): + blocks.append(block) + return blocks + + +@shared_task +@set_code_owner_attribute +def reset_student_course(course_id, learner_email, reset_by_user_email): + """ + Resets a learner's course progress + """ + user = get_user_by_username_or_email(learner_email) + reset_by_user = get_user_by_username_or_email(reset_by_user_email) + enrollment = CourseEnrollment.objects.get( + course=course_id, + user=user, + is_active=True, + ) + course_overview = enrollment.course_overview + course_reset_audit = CourseResetAudit.objects.get( + course_enrollment=enrollment, + status=CourseResetAudit.CourseResetStatus.ENQUEUED + ) + update_audit_status(course_reset_audit, CourseResetAudit.CourseResetStatus.IN_PROGRESS) + + try: + course = get_course(course_overview.id, depth=4) + blocks = get_blocks(course) + for data in blocks: + try: + reset_student_attempts(course.id, user, data.scope_ids.usage_id, reset_by_user, True) + except StudentModule.DoesNotExist: + pass + update_audit_status(course_reset_audit, CourseResetAudit.CourseResetStatus.COMPLETE) + except Exception as e: # pylint: disable=broad-except + logging.exception(f'Error occurred for Course Audit with ID {course_reset_audit.id}: {e}.') + update_audit_status(course_reset_audit, CourseResetAudit.CourseResetStatus.FAILED) diff --git a/lms/djangoapps/support/tests/test_tasks.py b/lms/djangoapps/support/tests/test_tasks.py new file mode 100644 index 0000000000..7f4818ce73 --- /dev/null +++ b/lms/djangoapps/support/tests/test_tasks.py @@ -0,0 +1,199 @@ +""" +Unit tests for reset_student_course task +""" + +from unittest.mock import patch, Mock, call + +from xmodule.modulestore.tests.factories import BlockFactory + +from lms.djangoapps.courseware.tests.test_submitting_problems import TestSubmittingProblems +from lms.djangoapps.courseware.models import StudentModule +from lms.djangoapps.support.tasks import reset_student_course +from lms.djangoapps.support.tests.factories import CourseResetAuditFactory, CourseResetCourseOptInFactory +from lms.djangoapps.support.models import CourseResetAudit +from common.djangoapps.student.models.course_enrollment import CourseEnrollment +from common.djangoapps.student.roles import SupportStaffRole +from common.djangoapps.student.tests.factories import UserFactory +from xmodule.video_block import VideoBlock + + +class ResetStudentCourse(TestSubmittingProblems): + """ Test reset_student_course task """ + USERNAME = "support" + EMAIL = "support@example.com" + PASSWORD = "support" + + def setUp(self): + """ + Set permissions, create a course and learner, enroll learner and opt into course reset + """ + super().setUp() + self.user = UserFactory(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + SupportStaffRole().add_users(self.user) + self.course_id = str(self.course.id) + self.enrollment = CourseEnrollment.objects.filter(user=self.student_user, course_id=self.course.id).first() + self.opt_in = CourseResetCourseOptInFactory.create(course_id=self.course.id) + self.audit = CourseResetAuditFactory.create( + course=self.opt_in, + course_enrollment=self.enrollment, + reset_by=self.user, + status=CourseResetAudit.CourseResetStatus.ENQUEUED + ) + self.p1 = '' + self.p2 = '' + self.p3 = '' + self.video = '' + + def basic_setup(self): + """ + Set up a simple course for testing basic grading functionality. + """ + grading_policy = { + "GRADER": [{ + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "HW", + "weight": 1.0 + }], + "GRADE_CUTOFFS": { + 'A': .9, + 'B': .33 + } + } + self.add_grading_policy(grading_policy) + + # set up a simple course with four problems + homework = self.add_graded_section_to_course('homework') + vertical = BlockFactory.create( + parent_location=homework.location, + category='vertical', + display_name='Unit 1', + ) + + self.p1 = self.add_dropdown_to_section(vertical.location, 'p1', 1) + self.p2 = self.add_dropdown_to_section(vertical.location, 'p2', 1) + self.p3 = self.add_dropdown_to_section(vertical.location, 'p3', 1) + video_sample_xml = """ + + """ + video_data = VideoBlock.parse_video_xml(video_sample_xml) + video_data.pop('source') + self.video = BlockFactory.create( + category='video', + parent_location=vertical.location, + **video_data + ) + + self.refresh_course() + + def test_reset_student_course(self): + """ Test that it resets student attempts """ + with patch( + 'lms.djangoapps.support.tasks.reset_student_attempts', + ) as mock_reset_student_attempts: + self.basic_setup() + reset_student_course(self.course_id, self.student_user.email, self.user.email) + + mock_reset_student_attempts.assert_has_calls([ + call( + self.course.id, + self.student_user, + self.p1.location, + self.user, + True + ), + call( + self.course.id, + self.student_user, + self.p2.location, + self.user, + True + ), + call( + self.course.id, + self.student_user, + self.p3.location, + self.user, + True + ), + call( + self.course.id, + self.student_user, + self.video.location, + self.user, + True + ) + ]) + + course_reset_audit = CourseResetAudit.objects.get(course_enrollment=self.enrollment) + self.assertIsNotNone(course_reset_audit.completed_at) + self.assertEqual(course_reset_audit.status, CourseResetAudit.CourseResetStatus.COMPLETE) + + def test_reset_student_course_student_module_not_found(self): + + with patch( + 'lms.djangoapps.support.tasks.reset_student_attempts', + Mock(side_effect=StudentModule.DoesNotExist()) + ) as mock_reset_student_attempts: + self.basic_setup() + reset_student_course(self.course_id, self.student_user.email, self.user.email) + mock_reset_student_attempts.assert_has_calls([ + call( + self.course.id, + self.student_user, + self.p1.location, + self.user, + True + ), + call( + self.course.id, + self.student_user, + self.p2.location, + self.user, + True + ), + call( + self.course.id, + self.student_user, + self.p3.location, + self.user, + True + ) + ]) + + course_reset_audit = CourseResetAudit.objects.get(course_enrollment=self.enrollment) + self.assertRaises(StudentModule.DoesNotExist, mock_reset_student_attempts) + self.assertIsNotNone(course_reset_audit.completed_at) + self.assertEqual(course_reset_audit.status, CourseResetAudit.CourseResetStatus.COMPLETE) + + @patch('lms.djangoapps.support.tasks.reset_student_attempts') + def test_reset_student_course_fail(self, mock_reset_student_attempts): + with patch( + 'lms.djangoapps.support.tasks.get_blocks', + Mock(side_effect=Exception()) + ): + reset_student_course(self.course_id, self.student_user.email, self.user.email) + mock_reset_student_attempts.assert_not_called() + course_reset_audit = CourseResetAudit.objects.get(course_enrollment=self.enrollment) + self.assertIsNone(course_reset_audit.completed_at) + self.assertEqual(course_reset_audit.status, CourseResetAudit.CourseResetStatus.FAILED) + + def test_reset_student_attempts_raise_exception(self): + with patch( + 'lms.djangoapps.support.tasks.reset_student_attempts', + Mock(side_effect=Exception()) + ) as mock_reset_student_attempts: + self.basic_setup() + reset_student_course(self.course_id, self.student_user.email, self.user.email) + mock_reset_student_attempts.assert_called_once() + course_reset_audit = CourseResetAudit.objects.get(course_enrollment=self.enrollment) + self.assertIsNone(course_reset_audit.completed_at) + self.assertEqual(course_reset_audit.status, CourseResetAudit.CourseResetStatus.FAILED) diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 4bf71a16d8..853a82fb3b 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -2377,7 +2377,8 @@ class TestResetCourseViewPost(SupportViewTestCase): response = self.client.post(self._url(username='does_not_exist'), data={'course_id': 'course-v1:aa+bb+c'}) self.assertEqual(response.status_code, 404) - def test_learner_course_reset(self): + @patch('lms.djangoapps.support.views.course_reset.reset_student_course') + def test_learner_course_reset(self, mock_reset_student_course): response = self.client.post(self._url(username=self.user.username), data={'course_id': self.course_id}) self.assertEqual(response.status_code, 201) self.assertEqual(response.data, { @@ -2386,12 +2387,16 @@ class TestResetCourseViewPost(SupportViewTestCase): 'can_reset': False, 'display_name': self.course.display_name }) + self.assertEqual( + mock_reset_student_course.delay.call_count, 1 + ) def test_course_not_opt_in(self): response = self.client.post(self._url(username=self.user.username), data={'course_id': 'course-v1:aa+bb+c'}) self.assertEqual(response.status_code, 404) - def test_course_reset_failed(self): + @patch('lms.djangoapps.support.views.course_reset.reset_student_course') + def test_course_reset_failed(self, mock_reset_student_course): course = CourseFactory.create( org='xx', course='yy', @@ -2416,6 +2421,9 @@ class TestResetCourseViewPost(SupportViewTestCase): status=CourseResetAudit.CourseResetStatus.FAILED ) response = self.client.post(self._url(username=self.user.username), data={'course_id': course.id}) + self.assertEqual( + mock_reset_student_course.delay.call_count, 1 + ) self.assertEqual(response.status_code, 200) def test_course_reset_dupe(self): diff --git a/lms/djangoapps/support/views/course_reset.py b/lms/djangoapps/support/views/course_reset.py index c8d4abb2ce..97c7b63d93 100644 --- a/lms/djangoapps/support/views/course_reset.py +++ b/lms/djangoapps/support/views/course_reset.py @@ -14,6 +14,7 @@ from lms.djangoapps.support.models import ( CourseResetCourseOptIn, CourseResetAudit ) +from ..tasks import reset_student_course User = get_user_model() @@ -131,7 +132,8 @@ class CourseResetAPIView(APIView): ): course_reset_audit.status = CourseResetAudit.CourseResetStatus.ENQUEUED course_reset_audit.save() - # Call celery task + reset_student_course.delay(course_id, user.email, request.user.email) + resp = { 'course_id': course_id, 'status': course_reset_audit.status_message(), @@ -159,7 +161,7 @@ class CourseResetAPIView(APIView): 'display_name': course_overview.display_name } - # Call celery task + reset_student_course.delay(course_id, user.email, request.user.email) return Response(resp, status=201) else: return Response(None, status=400)