Merge pull request #34350 from openedx/hajorg/au-1848-course-reset-celery-task

feat: add celery task to reset course progress for learner
This commit is contained in:
Jorg Are
2024-03-21 16:21:33 +01:00
committed by GitHub
4 changed files with 280 additions and 4 deletions

View File

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

View File

@@ -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 display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
from="1.0"
to="60.0">
<source src="http://www.example.com/file.mp4"/>
<track src="http://www.example.com/track"/>
</video>
"""
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)

View File

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

View File

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