diff --git a/lms/djangoapps/support/models.py b/lms/djangoapps/support/models.py index 7915d1aa91f1..9fa254fe6ecc 100644 --- a/lms/djangoapps/support/models.py +++ b/lms/djangoapps/support/models.py @@ -28,6 +28,14 @@ class CourseResetCourseOptIn(TimeStampedModel): def __str__(self): return f'{self.course_id} - {"ACTIVE" if self.active else "INACTIVE"}' + @staticmethod + def all_active(): + return CourseResetCourseOptIn.objects.filter(active=True) + + @staticmethod + def all_active_course_ids(): + return [course.course_id for course in CourseResetCourseOptIn.all_active()] + class CourseResetAudit(TimeStampedModel): """ @@ -57,3 +65,15 @@ class CourseResetStatus(TextChoices): default=CourseResetStatus.ENQUEUED, ) completed_at = DateTimeField(default=None, null=True, blank=True) + + def status_message(self): + """ Return a string message about the status of this audit """ + if self.status == self.CourseResetStatus.FAILED: + return f"Failed on {self.modified}" + if self.status == self.CourseResetStatus.ENQUEUED: + return f"Enqueued - Created {self.created} by {self.reset_by.username}" + if self.status == self.CourseResetStatus.COMPLETE: + return f"Completed on {self.completed_at} by {self.reset_by.username}" + if self.status == self.CourseResetStatus.IN_PROGRESS: + return f"In progress - Started on {self.modified} by {self.reset_by.username}" + return self.status diff --git a/lms/djangoapps/support/tests/factories.py b/lms/djangoapps/support/tests/factories.py new file mode 100644 index 000000000000..93f68b11b3c7 --- /dev/null +++ b/lms/djangoapps/support/tests/factories.py @@ -0,0 +1,29 @@ +""" Factories for course reset models """ +import factory +from factory.django import DjangoModelFactory +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory + + +from lms.djangoapps.support.models import ( + CourseResetCourseOptIn, + CourseResetAudit +) + + +class CourseResetCourseOptInFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-class-docstring + class Meta: + model = CourseResetCourseOptIn + + course_id = None + active = True + + +class CourseResetAuditFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-class-docstring + class Meta: + model = CourseResetAudit + + course = factory.SubFactory(CourseResetCourseOptInFactory) + course_enrollment = factory.SubFactory(CourseEnrollmentFactory) + reset_by = factory.SubFactory(UserFactory) + status = CourseResetAudit.CourseResetStatus.ENQUEUED + completed_at = None diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 51d847d21802..6338935470f4 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -54,7 +54,9 @@ 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 from lms.djangoapps.support.serializers import ProgramEnrollmentSerializer +from lms.djangoapps.support.tests.factories import CourseResetCourseOptInFactory, CourseResetAuditFactory from lms.djangoapps.verify_student.models import VerificationDeadline from lms.djangoapps.verify_student.services import IDVerificationService from lms.djangoapps.verify_student.tests.factories import SSOVerificationFactory @@ -2067,3 +2069,265 @@ def test_verified_in_another_course(self): # assert that most recent enrollment (current status) has other_course_approved status self.assertEqual(response_data['current_status']['onboarding_status'], 'other_course_approved') self.assertEqual(response_data['current_status']['course_id'], self.course_id) + + +@ddt.ddt +class TestResetCourseViewGET(SupportViewTestCase): + """ Tests for the list endpoint for course reset """ + + def _url(self, username): + """ Helper to generate URL """ + return reverse("support:course_reset", kwargs={'username_or_email': username}) + + def setUp(self): + """ + Set permissions, create an open course and learner, enroll learner and opt into course reset + """ + 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), + ) + self.course_id = str(self.course.id) + self.course_overview = CourseOverview.get_from_id(self.course.id) + self.learner = UserFactory.create() + self.enrollment = CourseEnrollmentFactory.create(user=self.learner, course_id=self.course.id) + self.opt_in = CourseResetCourseOptInFactory.create(course_id=self.course.id) + + 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 + response = self.client.get(self._url(learner)) + self.assertEqual(response.status_code, 200) + + actual_course_ids = [course['course_id'] for course in response.json()] + self.assertEqual(expected_course_ids, actual_course_ids) + + def test_no_enrollments(self): + """ When a learner has no enrollments, the endpoint should return an empty list """ + no_enrollment_learner = UserFactory.create() + self.assert_course_ids([], learner=no_enrollment_learner) + + def test_not_opted_in(self): + """ + If a learner is enrolled in a course that is not opted into the course reset feature, + 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) + self.assert_course_ids([self.course_id]) + + def test_deactivated_opt_in(self): + """ + 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 + self.opt_in.save() + + self.assert_course_ids([]) + + def test_deactivated_enrollment(self): + """ + 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 + self.enrollment.save() + + self.assert_course_ids([]) + + def assertResponse(self, expected_response, learner=None): + """ Helper to assert the contents of the response from the listing endpoint """ + learner = learner or self.learner + response = self.client.get(self._url(learner)) + self.assertEqual(response.status_code, 200) + + actual_response = response.json() + self.assertEqual(expected_response, actual_response) + return actual_response + + def test_course_not_started(self): + """ If a course is opted in but has not started, it should not be resettable """ + self.course_overview.start = self.now + timedelta(days=10) + self.course_overview.end = self.now + timedelta(days=11) + self.course_overview.save() + self.assertResponse([{ + 'course_id': self.course_id, + 'display_name': self.course_overview.display_name, + 'can_reset': False, + 'status': 'Course Not Started' + }]) + + def test_course_ended(self): + """ If a course is opted in but has ended, it should not be resettable """ + self.course_overview.start = self.now - timedelta(days=11) + self.course_overview.end = self.now - timedelta(days=10) + self.course_overview.save() + self.assertResponse([ + { + 'course_id': self.course_id, + 'display_name': self.course_overview.display_name, + 'can_reset': False, + 'status': 'Course Ended' + } + ]) + + @patch('lms.djangoapps.support.views.course_reset.user_has_passing_grade_in_course', return_value=True) + def test_user_has_passing_grade(self, _): + """ If a course is opted in but the learner has a passing grade, it should not be resettable """ + self.assertResponse([{ + 'course_id': self.course_id, + 'display_name': self.course_overview.display_name, + 'can_reset': False, + 'status': 'Learner Has Passing Grade' + }]) + + @patch('lms.djangoapps.support.views.course_reset.user_has_passing_grade_in_course', return_value=True) + def test_ended_with_passing_grade(self, _): + """ + If a course has ended and the learner has a passing grade, + the passing grade message should override the ended message + """ + self.course_overview.start = self.now - timedelta(days=11) + self.course_overview.end = self.now - timedelta(days=10) + self.assertResponse([{ + 'course_id': self.course_id, + 'display_name': self.course_overview.display_name, + 'can_reset': False, + 'status': 'Learner Has Passing Grade' + }]) + + def test_available_course(self): + """ If a course is opted in and had nothing stopping it from being reset, it should be resettable """ + self.assertResponse([{ + 'course_id': self.course_id, + 'display_name': self.course.display_name, + 'can_reset': True, + 'status': 'Available' + }]) + + @ddt.unpack + @ddt.data( + (CourseResetAudit.CourseResetStatus.ENQUEUED, False), + (CourseResetAudit.CourseResetStatus.IN_PROGRESS, False), + (CourseResetAudit.CourseResetStatus.FAILED, True), + (CourseResetAudit.CourseResetStatus.COMPLETE, False), + ) + def test_audit(self, audit_status, expected_can_reset): + """ + If a course enrollment has a CourseResetAudit associated with it, + it should not be resettable unless the audit is FAILED + """ + audit = CourseResetAuditFactory.create( + course=self.opt_in, + course_enrollment=self.enrollment, + status=audit_status, + ) + self.assertResponse([{ + 'course_id': self.course_id, + 'display_name': self.course.display_name, + 'can_reset': expected_can_reset, + 'status': audit.status_message() + }]) + + def test_multiple_courses(self): + """ Test for the behavior of multiple courses """ + courses = [CourseFactory.create(start=self.course.start, end=self.course.end) for _ in range(4)] + for course in courses: + CourseEnrollmentFactory.create(course_id=course.id, user=self.learner) + CourseResetCourseOptInFactory.create(course_id=course.id) + other_courses = [CourseFactory.create(start=self.course.start, end=self.course.end) for _ in range(4)] + for course in other_courses: + CourseEnrollmentFactory.create(course_id=course.id, user=self.learner) + + expected_response = [{ + 'course_id': self.course_id, + 'display_name': self.course.display_name, + 'can_reset': True, + 'status': 'Available' + }] + for course in courses: + expected_response.append({ + 'course_id': str(course.id), + 'display_name': course.display_name, + 'can_reset': True, + 'status': 'Available' + }) + + self.assertResponse(expected_response) + + def test_multiple_audits(self): + """ + If you have multiple audits for an enrollment (should only happen if process fails) + the information returned should be for the most recent ONLY + """ + daysago = lambda x: self.now - timedelta(days=x) + CourseResetAuditFactory.create( + course=self.opt_in, + course_enrollment=self.enrollment, + status=CourseResetAudit.CourseResetStatus.FAILED, + created=daysago(3), + modified=daysago(3), + ) + CourseResetAuditFactory.create( + course=self.opt_in, + course_enrollment=self.enrollment, + status=CourseResetAudit.CourseResetStatus.FAILED, + created=daysago(2), + modified=daysago(2), + ) + most_recent_audit = CourseResetAuditFactory.create( + course=self.opt_in, + course_enrollment=self.enrollment, + status=CourseResetAudit.CourseResetStatus.IN_PROGRESS, + ) + + response = self.assertResponse([{ + 'course_id': self.course_id, + 'display_name': self.course.display_name, + 'can_reset': False, + 'status': most_recent_audit.status_message() + }]) + + def test_multiple_failed_audits(self): + """ + If you have multiple audits for an enrollment and the most recent was a failure, + you should still be able to reset the course + """ + daysago = lambda x: self.now - timedelta(days=x) + CourseResetAuditFactory.create( + course=self.opt_in, + course_enrollment=self.enrollment, + status=CourseResetAudit.CourseResetStatus.FAILED, + created=daysago(3), + modified=daysago(3), + ) + CourseResetAuditFactory.create( + course=self.opt_in, + course_enrollment=self.enrollment, + status=CourseResetAudit.CourseResetStatus.FAILED, + created=daysago(2), + modified=daysago(2), + ) + most_recent_audit = CourseResetAuditFactory.create( + course=self.opt_in, + course_enrollment=self.enrollment, + status=CourseResetAudit.CourseResetStatus.FAILED, + ) + + response = self.assertResponse([{ + 'course_id': self.course_id, + 'display_name': self.course.display_name, + 'can_reset': True, + 'status': most_recent_audit.status_message() + }]) diff --git a/lms/djangoapps/support/urls.py b/lms/djangoapps/support/urls.py index 6cce0afbf9d1..02d8be8519fe 100644 --- a/lms/djangoapps/support/urls.py +++ b/lms/djangoapps/support/urls.py @@ -8,6 +8,7 @@ from .views.certificate import CertificatesSupportView from .views.contact_us import ContactUsView from .views.course_entitlements import EntitlementSupportView +from .views.course_reset import CourseResetAPIView from .views.enrollments import EnrollmentSupportListView, EnrollmentSupportView from .views.feature_based_enrollments import FeatureBasedEnrollmentsSupportView, FeatureBasedEnrollmentSupportAPIView from .views.index import index @@ -24,6 +25,7 @@ ) from .views.onboarding_status import OnboardingView + COURSE_ENTITLEMENTS_VIEW = EntitlementSupportView.as_view() app_name = 'support' @@ -84,4 +86,9 @@ r'onboarding_status/(?P[\w.@+-]+)?$', OnboardingView.as_view(), name='onboarding_status' ), + re_path( + r'course_reset/(?P[\w.@+-]+)?$', + CourseResetAPIView.as_view(), + name='course_reset' + ), ] diff --git a/lms/djangoapps/support/views/course_reset.py b/lms/djangoapps/support/views/course_reset.py new file mode 100644 index 000000000000..b274d4a97fa1 --- /dev/null +++ b/lms/djangoapps/support/views/course_reset.py @@ -0,0 +1,100 @@ +""" Views for the course reset feature """ + +from rest_framework.response import Response +from django.contrib.auth import get_user_model +from django.utils.decorators import method_decorator +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 +from lms.djangoapps.support.models import ( + CourseResetCourseOptIn, + CourseResetAudit +) + +User = get_user_model() + + +def can_enrollment_be_reset(course_enrollment): + """ + Args: enrollment (CourseEnrollment) + Returns: tuple (boolean, string) + [0]: whether or not the course can be reset + [1]: a status message to present to the learner + or None if there is nothing notable about the enrollment and it can be reset + """ + course_overview = course_enrollment.course_overview + if not course_overview.has_started(): + return False, "Course Not Started" + if course_overview.has_ended(): + return False, "Course Ended" + if user_has_passing_grade_in_course(course_enrollment): + return False, "Learner Has Passing Grade" + + try: + audit = course_enrollment.courseresetaudit_set.latest('modified') + except CourseResetAudit.DoesNotExist: + return True, None + + audit_status_message = audit.status_message() + if audit.status == CourseResetAudit.CourseResetStatus.FAILED: + return True, audit_status_message + return False, audit_status_message + + +class CourseResetAPIView(APIView): + """ + A view to handle requests related to the course reset feature. + GET: List applicable courses, their statuses, and if they can be reset + POST: Reset a course for the given learner + """ + + permission_classes = ( + IsAuthenticated, + ) + + @method_decorator(require_support_permission) + def get(self, request, username_or_email): + """ + List the enrollments for this user that are in courses that have opted into the + course reset feature, including information about past resets or resets in progress, and + whether or not the reset will be allowed to be done for each returned enrollment + + returns a list of dicts with the format [ + { + 'course_id': + 'display_name': + 'status': + 'can_reset': (boolean) + } + ] + """ + try: + user = get_user_by_username_or_email(username_or_email) + except User.DoesNotExist: + return Response([]) + all_enabled_resettable_course_ids = CourseResetCourseOptIn.all_active_course_ids() + course_enrollments = CourseEnrollment.objects.filter( + is_active=True, + user=user, + course__id__in=all_enabled_resettable_course_ids + ).select_related("course").prefetch_related("courseresetaudit_set") + + result = [] + for course_enrollment in course_enrollments: + course_overview = course_enrollment.course_overview + can_reset, status_message = can_enrollment_be_reset(course_enrollment) + result.append({ + 'course_id': str(course_overview.id), + 'display_name': course_overview.display_name, + 'can_reset': can_reset, + 'status': status_message if status_message else "Available" + }) + return Response(result) + + @method_decorator(require_support_permission) + def post(self, request, username_or_email): + """ Other Ticket """