diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index ed81d9101b..d904cdbddf 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -66,7 +66,7 @@ from openedx_events.learning.signals import ( COURSE_ENROLLMENT_CREATED, COURSE_UNENROLLMENT_COMPLETED, ) -from openedx_filters.learning.filters import CourseEnrollmentStarted +from openedx_filters.learning.filters import CourseEnrollmentStarted, CourseUnenrollmentStarted import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price from common.djangoapps.student.emails import send_proctoring_requirements_email @@ -1122,6 +1122,10 @@ class EnrollmentNotAllowed(CourseEnrollmentException): pass +class UnenrollmentNotAllowed(CourseEnrollmentException): + pass + + class CourseEnrollmentManager(models.Manager): """ Custom manager for CourseEnrollment with Table-level filter methods. @@ -1767,6 +1771,14 @@ class CourseEnrollment(models.Model): try: record = cls.objects.get(user=user, course_id=course_id) + + try: + # .. filter_implemented_name: CourseUnenrollmentStarted + # .. filter_type: org.openedx.learning.course.unenrollment.started.v1 + record = CourseUnenrollmentStarted.run_filter(enrollment=record) + except CourseUnenrollmentStarted.PreventUnenrollment as exc: + raise UnenrollmentNotAllowed(str(exc)) from exc + record.update_enrollment(is_active=False, skip_refund=skip_refund) except cls.DoesNotExist: diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index 2f8420d01c..e379f14821 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -1,13 +1,15 @@ """ -Test that various filters are fired for models in the student app. +Test that various filters are fired for models/views in the student app. """ from django.test import override_settings +from django.urls import reverse +from openedx_filters import PipelineStep +from openedx_filters.learning.filters import CourseEnrollmentStarted, CourseUnenrollmentStarted +from rest_framework import status from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from openedx_filters.learning.filters import CourseEnrollmentStarted -from openedx_filters import PipelineStep -from common.djangoapps.student.models import CourseEnrollment, EnrollmentNotAllowed +from common.djangoapps.student.models import CourseEnrollment, EnrollmentNotAllowed, UnenrollmentNotAllowed from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -24,6 +26,23 @@ class TestEnrollmentPipelineStep(PipelineStep): return {"mode": "honor"} +class TestUnenrollmentPipelineStep(PipelineStep): + """ + Utility function used when getting steps for pipeline. + """ + + def run_filter(self, enrollment): # pylint: disable=arguments-differ + """Pipeline steps that modifies user's profile before unenrolling.""" + if enrollment.mode == "no-id-professional": + raise CourseUnenrollmentStarted.PreventUnenrollment( + "You can't un-enroll from this site." + ) + + enrollment.user.profile.set_meta({"unenrolled_from": str(enrollment.course_id)}) + enrollment.user.profile.save() + return {} + + @skip_unless_lms class EnrollmentFiltersTest(ModuleStoreTestCase): """ @@ -102,3 +121,117 @@ class EnrollmentFiltersTest(ModuleStoreTestCase): self.assertEqual('audit', enrollment.mode) self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + +@skip_unless_lms +class UnenrollmentFiltersTest(ModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with the unenrollment process through the unenroll method. + + This class guarantees that the following filters are triggered during the user's unenrollment: + + - CourseUnenrollmentStarted + """ + + USERNAME = "test" + EMAIL = "test@example.com" + PASSWORD = "password" + + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.course.unenrollment.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestUnenrollmentPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_unenrollment_filter_executed(self): + """ + Test whether the student unenrollment filter is triggered before the user's + unenrollment process. + + Expected result: + - CourseUnenrollmentStarted is triggered and executes TestUnenrollmentPipelineStep. + - The user's profile has unenrolled_from in its meta field. + """ + CourseEnrollment.enroll(self.user, self.course.id, mode="audit") + + CourseEnrollment.unenroll(self.user, self.course.id) + + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.course.unenrollment.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestUnenrollmentPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_unenrollment_filter_prevent_unenroll(self): + """ + Test prevent the user's unenrollment through a pipeline step. + + Expected result: + - CourseUnenrollmentStarted is triggered and executes TestUnenrollmentPipelineStep. + - The user can't unenroll. + """ + CourseEnrollment.enroll(self.user, self.course.id, mode="no-id-professional") + + with self.assertRaises(UnenrollmentNotAllowed): + CourseEnrollment.unenroll(self.user, self.course.id) + + @override_settings(OPEN_EDX_FILTERS_CONFIG={}) + def test_unenrollment_without_filter_configuration(self): + """ + Test usual unenrollment process without filter's intervention. + + Expected result: + - CourseUnenrollmentStarted does not have any effect on the unenrollment process. + - The unenrollment process ends successfully. + """ + CourseEnrollment.enroll(self.user, self.course.id, mode="audit") + + CourseEnrollment.unenroll(self.user, self.course.id) + + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.course.unenrollment.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestUnenrollmentPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_unenrollment_blocked_by_filter(self): + """ + Test cannot unenroll using change_enrollment view course when UnenrollmentNotAllowed is + raised by unenroll method. + + Expected result: + - CourseUnenrollmentStarted does not have any effect on the unenrollment process. + - The unenrollment process ends successfully. + """ + CourseEnrollment.enroll(self.user, self.course.id, mode="no-id-professional") + params = { + "enrollment_action": "unenroll", + "course_id": str(self.course.id) + } + + response = self.client.post(reverse("change_enrollment"), params) + + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertEqual("You can't un-enroll from this site.", response.content.decode("utf-8")) diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 4afc8c7eed..261d814ba3 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -69,6 +69,7 @@ from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable= PendingSecondaryEmailChange, Registration, RegistrationCookieConfiguration, + UnenrollmentNotAllowed, UserAttribute, UserProfile, UserSignupSource, @@ -410,7 +411,11 @@ def change_enrollment(request, check_access=True): if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES: return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course")) - CourseEnrollment.unenroll(user, course_id) + try: + CourseEnrollment.unenroll(user, course_id) + except UnenrollmentNotAllowed as exc: + return HttpResponseBadRequest(str(exc)) + REFUND_ORDER.send(sender=None, course_enrollment=enrollment) return HttpResponse() else: diff --git a/lms/static/js/learner_dashboard/views/unenroll_view.js b/lms/static/js/learner_dashboard/views/unenroll_view.js index c4b0f2751d..40d6affa9d 100644 --- a/lms/static/js/learner_dashboard/views/unenroll_view.js +++ b/lms/static/js/learner_dashboard/views/unenroll_view.js @@ -75,6 +75,11 @@ class UnenrollView extends Backbone.View { this.switchToSlideOne(); this.$('.reasons_survey:first .submit_reasons').click(this.switchToSlideTwo.bind(this)); } + } else if (xhr.status === 400) { + $('#unenroll_error').text( + xhr.responseText, + ).stop() + .css('display', 'block'); } else if (xhr.status === 403) { location.href = `${this.urls.signInUser}?course_id=${ encodeURIComponent($('#unenroll_course_id').val())}&enrollment_action=unenroll`;