diff --git a/openedx/core/djangoapps/enrollments/api.py b/openedx/core/djangoapps/enrollments/api.py index 6f3b22d7dd..ec0b3a6519 100644 --- a/openedx/core/djangoapps/enrollments/api.py +++ b/openedx/core/djangoapps/enrollments/api.py @@ -199,7 +199,16 @@ def get_enrollment(username, course_id): return _data_api().get_course_enrollment(username, course_id) -def add_enrollment(username, course_id, mode=None, is_active=True, enrollment_attributes=None, enterprise_uuid=None): +def add_enrollment( + username, + course_id, + mode=None, + is_active=True, + enrollment_attributes=None, + enterprise_uuid=None, + force_enrollment=False, + include_expired=False +): """Enrolls a user in a course. Enrolls a user in a course. If the mode is not specified, this will default to `CourseMode.DEFAULT_MODE_SLUG`. @@ -213,6 +222,8 @@ def add_enrollment(username, course_id, mode=None, is_active=True, enrollment_at defaults to True. enrollment_attributes (list): Attributes to be set the enrollment. enterprise_uuid (str): Add course enterprise uuid + force_enrollment (bool): Enroll user even if course enrollment_end date is expired + include_expired (bool): Boolean denoting whether expired course modes should be included. Returns: A serializable dictionary of the new course enrollment. @@ -250,8 +261,10 @@ def add_enrollment(username, course_id, mode=None, is_active=True, enrollment_at """ if mode is None: mode = _default_course_mode(course_id) - validate_course_mode(course_id, mode, is_active=is_active) - enrollment = _data_api().create_course_enrollment(username, course_id, mode, is_active, enterprise_uuid) + validate_course_mode(course_id, mode, is_active=is_active, include_expired=include_expired) + enrollment = _data_api().create_course_enrollment( + username, course_id, mode, is_active, enterprise_uuid, force_enrollment=force_enrollment + ) if enrollment_attributes is not None: set_enrollment_attributes(username, course_id, enrollment_attributes) diff --git a/openedx/core/djangoapps/enrollments/data.py b/openedx/core/djangoapps/enrollments/data.py index fbea06edff..3a242ecb34 100644 --- a/openedx/core/djangoapps/enrollments/data.py +++ b/openedx/core/djangoapps/enrollments/data.py @@ -115,7 +115,7 @@ def get_user_enrollments(course_key): ).order_by('created') -def create_course_enrollment(username, course_id, mode, is_active, enterprise_uuid=None): +def create_course_enrollment(username, course_id, mode, is_active, enterprise_uuid=None, force_enrollment=False): """Create a new course enrollment for the given user. Creates a new course enrollment for the specified user username. @@ -126,6 +126,7 @@ def create_course_enrollment(username, course_id, mode, is_active, enterprise_uu mode (str): (Optional) The mode for the new enrollment. is_active (boolean): (Optional) Determines if the enrollment is active. enterprise_uuid (str): Add course enterprise uuid + force_enrollment (bool): Enroll user even if course enrollment_end date is expired Returns: A serializable dictionary representing the new course enrollment. @@ -147,7 +148,9 @@ def create_course_enrollment(username, course_id, mode, is_active, enterprise_uu raise UserNotFoundError(msg) # lint-amnesty, pylint: disable=raise-missing-from try: - enrollment = CourseEnrollment.enroll(user, course_key, check_access=True, enterprise_uuid=enterprise_uuid) + enrollment = CourseEnrollment.enroll( + user, course_key, check_access=True, can_upgrade=force_enrollment, enterprise_uuid=enterprise_uuid + ) return _update_enrollment(enrollment, is_active=is_active, mode=mode) except NonExistentCourseError as err: raise CourseNotFoundError(str(err)) # lint-amnesty, pylint: disable=raise-missing-from diff --git a/openedx/core/djangoapps/enrollments/tests/fake_data_api.py b/openedx/core/djangoapps/enrollments/tests/fake_data_api.py index afe972cb45..31e00582da 100644 --- a/openedx/core/djangoapps/enrollments/tests/fake_data_api.py +++ b/openedx/core/djangoapps/enrollments/tests/fake_data_api.py @@ -36,9 +36,18 @@ def get_course_enrollment(student_id, course_id): return _get_fake_enrollment(student_id, course_id) -def create_course_enrollment(student_id, course_id, mode='honor', is_active=True, enterprise_uuid=None): +def create_course_enrollment( + student_id, course_id, mode='honor', is_active=True, enterprise_uuid=None, force_enrollment=False +): """Stubbed out Enrollment creation request. """ - return add_enrollment(student_id, course_id, mode=mode, is_active=is_active, enterprise_uuid=enterprise_uuid) + return add_enrollment( + student_id, + course_id, + mode=mode, + is_active=is_active, + enterprise_uuid=enterprise_uuid, + force_enrollment=force_enrollment + ) def update_course_enrollment(student_id, course_id, mode=None, is_active=None): @@ -74,7 +83,9 @@ def _get_fake_course_info(course_id, include_expired=False): return course -def add_enrollment(student_id, course_id, is_active=True, mode='honor', enterprise_uuid=None): +def add_enrollment( + student_id, course_id, is_active=True, mode='honor', enterprise_uuid=None, force_enrollment=False +): """Append an enrollment to the enrollments array.""" enrollment = { "created": datetime.datetime.now(), diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py index fca14e9c84..796a419a02 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/tests/test_views.py @@ -67,6 +67,7 @@ class EnrollmentTestMixin: max_mongo_calls=0, linked_enterprise_customer=None, cohort=None, + force_enrollment=False, ): """ Enroll in the course and verify the response's status code. If the expected status is 200, also validates @@ -84,6 +85,7 @@ class EnrollmentTestMixin: 'course_id': course_id }, 'user': username, + 'force_enrollment': force_enrollment, 'enrollment_attributes': enrollment_attributes } @@ -228,6 +230,75 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase, Ente assert is_active assert course_mode == enrollment_mode + @ddt.data( + # Default (no course modes in the database) + # Expect that users are automatically enrolled as the default + ([CourseMode.VERIFIED], CourseMode.VERIFIED, False), + + # Audit / Verified + # We should always go to the "choose your course" page. + # We should also be enrolled as the default. + ([CourseMode.VERIFIED], CourseMode.VERIFIED, True), + ) + @ddt.unpack + def test_force_enrollment(self, course_modes, enrollment_mode, force_enrollment): + # Create the course modes (if any) required for this test case + start_date = datetime.datetime(2021, 12, 1, 5, 0, 0, tzinfo=pytz.UTC) + end_date = datetime.datetime(2022, 12, 1, 5, 0, 0, tzinfo=pytz.UTC) + self.course = CourseFactory.create( + emit_signals=True, + start=start_date, + end=end_date, + enrollment_start=start_date, + enrollment_end=end_date, + ) + for mode_slug in course_modes: + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=mode_slug, + mode_display_name=mode_slug, + ) + + # If a user enroll himself in expired course + # whether force_enrollmet is True or False + self.assert_enrollment_status( + mode=CourseMode.VERIFIED, + expected_status=status.HTTP_403_FORBIDDEN, + force_enrollment=force_enrollment, + ) + + self.client.logout() + AdminFactory.create(username='global_staff', email='global_staff@example.com', password=self.PASSWORD) + self.client.login(username="global_staff", password=self.PASSWORD) + + if force_enrollment: + # Create an enrollment + resp = self.assert_enrollment_status( + username=self.USERNAME, + mode=CourseMode.VERIFIED, + force_enrollment=force_enrollment, + ) + + # Verify that the response contains the correct course_name + data = json.loads(resp.content.decode('utf-8')) + assert self.course.display_name_with_default == data['course_details']['course_name'] + + # Verify that the enrollment was created correctly + assert CourseEnrollment.is_enrolled(self.user, self.course.id) + course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + assert is_active + assert course_mode == enrollment_mode + else: + # If a staff user enroll other user in expired course + # This will raise the CourseEnrollmentClosedError excecption + # and return status will be 400 + self.assert_enrollment_status( + username=self.USERNAME, + mode=CourseMode.VERIFIED, + expected_status=status.HTTP_400_BAD_REQUEST, + force_enrollment=force_enrollment, + ) + def test_check_enrollment(self): CourseModeFactory.create( course_id=self.course.id, diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index ffd883667f..8124db23ee 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -764,6 +764,17 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): consent_client.provide_consent(**kwargs) enrollment_attributes = request.data.get('enrollment_attributes') + force_enrollment = request.data.get('force_enrollment') + # Check if the force enrollment status is None or a Boolean + if force_enrollment is not None and not isinstance(force_enrollment, bool): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + 'message': ("'{value}' is an invalid force enrollment status.").format(value=force_enrollment) + } + ) + # Only a staff user role can enroll a user forcefully + force_enrollment = force_enrollment and GlobalStaff().has_user(request.user) enrollment = api.get_enrollment(username, str(course_id)) mode_changed = enrollment and mode is not None and enrollment['mode'] != mode active_changed = enrollment and is_active is not None and enrollment['is_active'] != is_active @@ -809,7 +820,10 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): mode=mode, is_active=is_active, enrollment_attributes=enrollment_attributes, - enterprise_uuid=request.data.get('enterprise_uuid') + enterprise_uuid=request.data.get('enterprise_uuid'), + force_enrollment=force_enrollment, + # If we are creating enrollment by staff user with force_enrollment, we should allow expired modes + include_expired=force_enrollment ) cohort_name = request.data.get('cohort')