fix: clean up course reset post endpoint (#34419)

This commit is contained in:
Jansen Kantor
2024-03-26 10:00:39 -04:00
committed by GitHub
parent 7f62080c95
commit ce1064bf5f
2 changed files with 131 additions and 145 deletions

View File

@@ -54,7 +54,7 @@ from common.djangoapps.student.tests.factories import (
from common.djangoapps.third_party_auth.tests.factories import SAMLProviderConfigFactory
from common.test.utils import disable_signal
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from lms.djangoapps.support.models import CourseResetAudit, CourseResetCourseOptIn
from lms.djangoapps.support.models import CourseResetAudit
from lms.djangoapps.support.serializers import ProgramEnrollmentSerializer
from lms.djangoapps.support.tests.factories import CourseResetCourseOptInFactory, CourseResetAuditFactory
from lms.djangoapps.verify_student.models import VerificationDeadline
@@ -2071,10 +2071,10 @@ class TestOnboardingView(SupportViewTestCase, ProctoredExamTestCase):
self.assertEqual(response_data['current_status']['course_id'], self.course_id)
@ddt.ddt
class TestResetCourseViewGET(SupportViewTestCase):
""" Tests for the list endpoint for course reset """
class ResetCourseViewTestBase(SupportViewTestCase):
"""
Shared base class for course reset view tests
"""
def _url(self, username):
""" Helper to generate URL """
return reverse("support:course_reset", kwargs={'username_or_email': username})
@@ -2086,7 +2086,6 @@ class TestResetCourseViewGET(SupportViewTestCase):
super().setUp()
SupportStaffRole().add_users(self.user)
self.now = datetime.now().replace(tzinfo=UTC)
self.course = CourseFactory.create(
start=self.now - timedelta(days=90),
end=self.now + timedelta(days=90),
@@ -2097,6 +2096,11 @@ class TestResetCourseViewGET(SupportViewTestCase):
self.enrollment = CourseEnrollmentFactory.create(user=self.learner, course_id=self.course.id)
self.opt_in = CourseResetCourseOptInFactory.create(course_id=self.course.id)
@ddt.ddt
class TestResetCourseListView(ResetCourseViewTestBase):
""" Tests for the list endpoint for course reset """
def assert_course_ids(self, expected_course_ids, learner=None):
""" Helper that asserts the course ids that will be returned from the listing endpoint """
learner = learner or self.learner
@@ -2117,7 +2121,7 @@ class TestResetCourseViewGET(SupportViewTestCase):
it will not be returned by the endpoint
"""
non_opted_in_course = CourseFactory.create()
enrollment = CourseEnrollmentFactory.create(user=self.learner, course_id=non_opted_in_course.id)
CourseEnrollmentFactory.create(user=self.learner, course_id=non_opted_in_course.id)
self.assert_course_ids([self.course_id])
def test_deactivated_opt_in(self):
@@ -2125,7 +2129,6 @@ class TestResetCourseViewGET(SupportViewTestCase):
If a learner is enrolled in a course that has opted in, but that opt-in is
deactivated, it will not be returned from the endpoint
"""
response = self.client.get(self._url(self.learner))
self.assert_course_ids([self.course_id])
self.opt_in.active = False
@@ -2138,7 +2141,6 @@ class TestResetCourseViewGET(SupportViewTestCase):
If a learner's enrollment in an opted in course is deactivated,
the course will not be returned by the endpoint
"""
response = self.client.get(self._url(self.learner))
self.assert_course_ids([self.course_id])
self.enrollment.is_active = False
@@ -2146,10 +2148,9 @@ class TestResetCourseViewGET(SupportViewTestCase):
self.assert_course_ids([])
def assertResponse(self, expected_response, learner=None):
def assertResponse(self, expected_response):
""" Helper to assert the contents of the response from the listing endpoint """
learner = learner or self.learner
response = self.client.get(self._url(learner))
response = self.client.get(self._url(self.learner))
self.assertEqual(response.status_code, 200)
actual_response = response.json()
@@ -2350,60 +2351,74 @@ class TestResetCourseViewGET(SupportViewTestCase):
}])
class TestResetCourseViewPost(SupportViewTestCase):
class TestResetCourseCreateView(ResetCourseViewTestBase):
"""
Tests for creating course request
Tests for POST endpoint for performing course reset
"""
def setUp(self):
super().setUp()
SupportStaffRole().add_users(self.user)
self.course_id = 'course-v1:a+b+c'
self.other_user = User.objects.create(username='otheruser', password='test')
self.course = CourseFactory.create(
org='a',
course='b',
run='c',
enable_proctored_exams=True,
proctoring_provider=settings.PROCTORING_BACKENDS['DEFAULT'],
)
self.enrollment = CourseEnrollmentFactory(
is_active=True,
mode='verified',
course_id=self.course.id,
user=self.user
)
self.opt_in = CourseResetCourseOptInFactory.create(course_id=self.course.id)
self.other_course = CourseFactory.create(
org='x',
course='y',
run='z',
def request(self, username=None, course_id=None, comment=None):
""" Helper to perform request """
username = username or self.learner.username
return self.client.post(
self._url(username),
data={
"course_id": course_id if course_id else self.course_id,
"comment": comment if comment else ""
}
)
def _url(self, username):
return reverse("support:course_reset", kwargs={'username_or_email': username})
def assert_error_response(self, response, expected_status_code, expected_error_message):
""" Helper to assert status code and error message """
self.assertEqual(response.status_code, expected_status_code)
self.assertEqual(response.data['error'], expected_error_message)
def test_wrong_username(self):
"""
Test that a request with a username which does not exits returns 404
"""
response = self.client.post(self._url(username='does_not_exist'), data={'course_id': 'course-v1:aa+bb+c'})
self.assertEqual(response.status_code, 404)
""" A request with a username which does not exits returns 404 """
response = self.request(username='does_not_exist')
self.assert_error_response(response, 404, "User does not exist")
def test_invalid_course_id(self):
""" A request for an invalid course id returns 400 """
response = self.request(course_id='thisisnotacourseid')
self.assert_error_response(response, 400, "invalid course id")
def test_missing_course_id(self):
""" A request without a course id returns 400 """
response = self.client.post(self._url(self.learner.username))
self.assert_error_response(response, 400, "Must specify course id")
def test_course_not_opt_in(self):
""" A request for a course which isn't opted into the feature returns 404 """
self.opt_in.active = False
self.opt_in.save()
response = self.request()
self.assert_error_response(response, 404, "Course is not eligible")
def test_unenrolled(self):
""" A request for a learner who isn't enrolled in the given course returns a 404 """
self.enrollment.is_active = False
self.enrollment.save()
response = self.request()
self.assert_error_response(response, 404, "Learner is not enrolled in course")
@patch('lms.djangoapps.support.views.course_reset.can_enrollment_be_reset')
def test_cannot_reset(self, mock_can_reset):
""" A request for a course which isn't able to be reset returns a 404 """
mock_status = str(uuid4())
mock_can_reset.return_value = (False, mock_status)
response = self.request()
self.assert_error_response(response, 400, f"Cannot reset course: {mock_status}")
@patch('lms.djangoapps.support.views.course_reset.reset_student_course')
def test_learner_course_reset(self, mock_reset_student_course):
""" Happy path test """
comment = str(uuid4())
response = self.client.post(
self._url(username=self.user.username),
data={
'course_id': self.course_id,
'comment': comment,
}
)
# A request for a given learner and course with a comment should return a 201
response = self.request(comment=comment)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data, {
'course_id': self.course_id,
@@ -2412,49 +2427,40 @@ class TestResetCourseViewPost(SupportViewTestCase):
'comment': comment,
'display_name': self.course.display_name
})
# The reset task should be queued
mock_reset_student_course.delay.assert_called_once_with(self.course_id, self.learner.id, self.user.id)
# And an audit should be created as ENQUEUED
self.assertEqual(
mock_reset_student_course.delay.call_count, 1
self.enrollment.courseresetaudit_set.first().status,
CourseResetAudit.CourseResetStatus.ENQUEUED
)
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)
@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',
run='zz',
)
enrollment = CourseEnrollmentFactory(
is_active=True,
mode='verified',
course_id=course.id,
user=self.user
)
opt_in_course = CourseResetCourseOptIn.objects.create(
course_id=course.id,
active=True
)
""" An audit that has failed previously should be able to be run successfully """
CourseResetAudit.objects.create(
course=opt_in_course,
course_enrollment=enrollment,
reset_by=self.other_user,
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):
CourseResetAuditFactory.create(
course=self.opt_in,
course_enrollment=self.enrollment,
reset_by=self.user,
status=CourseResetAudit.CourseResetStatus.FAILED
)
response2 = self.client.post(self._url(username=self.user.username), data={'course_id': self.course_id})
self.assertEqual(response2.status_code, 204)
response = self.request()
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data, {
'course_id': self.course_id,
'status': response.data['status'],
'can_reset': False,
'comment': '',
'display_name': self.course.display_name
})
mock_reset_student_course.delay.assert_called_once_with(self.course_id, self.learner.id, self.user.id)
def test_course_reset_already_reset(self):
""" A course that has an audit that hasn't failed should not be allowed to be run again """
additional_audit = CourseResetAuditFactory.create(
course=self.opt_in,
course_enrollment=self.enrollment,
status=CourseResetAudit.CourseResetStatus.ENQUEUED
)
response = self.request()
self.assert_error_response(response, 400, f"Cannot reset course: {additional_audit.status_message()}")

View File

@@ -3,10 +3,11 @@
from rest_framework.response import Response
from django.contrib.auth import get_user_model
from django.utils.decorators import method_decorator
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from common.djangoapps.student.models import CourseEnrollment, get_user_by_username_or_email
from common.djangoapps.student.helpers import user_has_passing_grade_in_course
from lms.djangoapps.support.decorators import require_support_permission
@@ -118,67 +119,46 @@ class CourseResetAPIView(APIView):
'course_id': <course id>
'display_name': <course display name>
'status': <status of the enrollment wrt/reset, to be displayed to user>
'comment': <optional comment made by support staff performing reset>
'can_reset': (boolean) <can the course be reset for this learner>
}
"""
comment = request.data.get('comment', '')
course_id = request.data['course_id']
try:
course_id = request.data['course_id']
course_key = CourseKey.from_string(course_id)
user = get_user_by_username_or_email(username_or_email)
opt_in_course = CourseResetCourseOptIn.objects.get(course_id=course_key, active=True)
except KeyError:
return Response({'error': 'Must specify course id'}, status=400)
except InvalidKeyError:
return Response({'error': 'invalid course id'}, status=400)
except User.DoesNotExist:
return Response({'error': 'User does not exist'}, status=404)
try:
opt_in_course = CourseResetCourseOptIn.objects.get(course_id=course_id)
except CourseResetCourseOptIn.DoesNotExist:
return Response({'error': 'Course is not eligible'}, status=404)
enrollment = CourseEnrollment.objects.get(
course=course_id,
user=user,
is_active=True
if not CourseEnrollment.is_enrolled(user, course_key):
return Response({'error': 'Learner is not enrolled in course'}, status=404)
course_enrollment = CourseEnrollment.get_enrollment(user, course_key)
can_reset, status_message = can_enrollment_be_reset(course_enrollment)
if not can_reset:
return Response({'error': f'Cannot reset course: {status_message}'}, status=400)
course_reset_audit = CourseResetAudit.objects.create(
course=opt_in_course,
course_enrollment=course_enrollment,
reset_by=request.user,
comment=request.data.get('comment', ''),
)
user_passed = user_has_passing_grade_in_course(enrollment=enrollment)
course_overview = enrollment.course_overview
course_reset_audit = CourseResetAudit.objects.filter(course_enrollment=enrollment).first()
if course_reset_audit and (
course_reset_audit.status == CourseResetAudit.CourseResetStatus.FAILED
and not user_passed
):
course_reset_audit.status = CourseResetAudit.CourseResetStatus.ENQUEUED
course_reset_audit.comment = comment
course_reset_audit.save()
reset_student_course.delay(course_id, user.email, request.user.email)
resp = {
'course_id': course_id,
'status': course_reset_audit.status_message(),
'can_reset': False,
'comment': course_reset_audit.comment,
'display_name': course_enrollment.course_overview.display_name
}
resp = {
'course_id': course_id,
'status': course_reset_audit.status_message(),
'can_reset': False,
'display_name': course_overview.display_name
}
return Response(resp, status=200)
elif course_reset_audit and course_reset_audit.status in (
CourseResetAudit.CourseResetStatus.IN_PROGRESS,
CourseResetAudit.CourseResetStatus.ENQUEUED
):
return Response(None, status=204)
if enrollment and opt_in_course and not user_passed:
course_reset_audit = CourseResetAudit.objects.create(
course=opt_in_course,
course_enrollment=enrollment,
reset_by=request.user,
comment=comment,
)
resp = {
'course_id': course_id,
'status': course_reset_audit.status_message(),
'can_reset': False,
'comment': comment,
'display_name': course_overview.display_name
}
reset_student_course.delay(course_id, user.email, request.user.email)
return Response(resp, status=201)
else:
return Response(None, status=400)
reset_student_course.delay(course_id, user.id, request.user.id)
return Response(resp, status=201)