diff --git a/common/djangoapps/enrollment/api.py b/common/djangoapps/enrollment/api.py index a683398f61..7f4c8b576e 100644 --- a/common/djangoapps/enrollment/api.py +++ b/common/djangoapps/enrollment/api.py @@ -192,7 +192,7 @@ def add_enrollment(user_id, course_id, mode=None, is_active=True): return _data_api().create_course_enrollment(user_id, course_id, mode, is_active) -def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_attributes=None): +def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_attributes=None, include_expired=False): """Updates the course mode for the enrolled user. Update a course enrollment for the given user and course. @@ -205,6 +205,7 @@ def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_ mode (str): The new course mode for this enrollment. is_active (bool): Sets whether the enrollment is active or not. enrollment_attributes (list): Attributes to be set the enrollment. + include_expired (bool): Boolean denoting whether expired course modes should be included. Returns: A serializable dictionary representing the updated enrollment. @@ -241,7 +242,7 @@ def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_ """ if mode is not None: - _validate_course_mode(course_id, mode, is_active=is_active) + _validate_course_mode(course_id, mode, is_active=is_active, include_expired=include_expired) enrollment = _data_api().update_course_enrollment(user_id, course_id, mode=mode, is_active=is_active) if enrollment is None: msg = u"Course Enrollment not found for user {user} in course {course}".format(user=user_id, course=course_id) @@ -393,7 +394,7 @@ def _default_course_mode(course_id): return CourseMode.DEFAULT_MODE_SLUG -def _validate_course_mode(course_id, mode, is_active=None): +def _validate_course_mode(course_id, mode, is_active=None, include_expired=False): """Checks to see if the specified course mode is valid for the course. If the requested course mode is not available for the course, raise an error with corresponding @@ -405,6 +406,7 @@ def _validate_course_mode(course_id, mode, is_active=None): Keyword Arguments: is_active (bool): Whether the enrollment is to be activated or deactivated. + include_expired (bool): Boolean denoting whether expired course modes should be included. Returns: None @@ -414,7 +416,9 @@ def _validate_course_mode(course_id, mode, is_active=None): """ # If the client has requested an enrollment deactivation, we want to include expired modes # in the set of available modes. This allows us to unenroll users from expired modes. - include_expired = not is_active if is_active is not None else False + # If include_expired is set as True we should not redetermine its value. + if not include_expired: + include_expired = not is_active if is_active is not None else False course_enrollment_info = _data_api().get_course_enrollment_info(course_id, include_expired=include_expired) course_modes = course_enrollment_info["course_modes"] diff --git a/common/djangoapps/enrollment/tests/fake_data_api.py b/common/djangoapps/enrollment/tests/fake_data_api.py index ec9fa519b3..f9ec55a33d 100644 --- a/common/djangoapps/enrollment/tests/fake_data_api.py +++ b/common/djangoapps/enrollment/tests/fake_data_api.py @@ -21,6 +21,8 @@ _COURSES = [] _ENROLLMENT_ATTRIBUTES = [] +_VERIFIED_MODE_EXPIRED = [] + # pylint: disable=unused-argument def get_course_enrollments(student_id): @@ -50,7 +52,7 @@ def update_course_enrollment(student_id, course_id, mode=None, is_active=None): def get_course_enrollment_info(course_id, include_expired=False): """Stubbed out Enrollment data request.""" - return _get_fake_course_info(course_id) + return _get_fake_course_info(course_id, include_expired) def _get_fake_enrollment(student_id, course_id): @@ -60,10 +62,14 @@ def _get_fake_enrollment(student_id, course_id): return enrollment -def _get_fake_course_info(course_id): +def _get_fake_course_info(course_id, include_expired=False): """Get a course from the courses array.""" + # if verified mode is expired and include expired is false + # then remove the verified mode from the course. for course in _COURSES: if course_id == course['course_id']: + if course_id in _VERIFIED_MODE_EXPIRED and not include_expired: + course['course_modes'] = [mode for mode in course['course_modes'] if mode['slug'] != 'verified'] return course @@ -97,6 +103,11 @@ def get_enrollment_attributes(user_id, course_id): return _ENROLLMENT_ATTRIBUTES +def set_expired_mode(course_id): + """Set course verified mode as expired.""" + _VERIFIED_MODE_EXPIRED.append(course_id) + + def add_course(course_id, enrollment_start=None, enrollment_end=None, invite_only=False, course_modes=None): """Append course to the courses array.""" course_info = { @@ -122,3 +133,5 @@ def reset(): _COURSES = [] global _ENROLLMENTS # pylint: disable=global-statement _ENROLLMENTS = [] + global _VERIFIED_MODE_EXPIRED # pylint: disable=global-statement + _VERIFIED_MODE_EXPIRED = [] diff --git a/common/djangoapps/enrollment/tests/test_api.py b/common/djangoapps/enrollment/tests/test_api.py index f941db37e9..e5461d376b 100644 --- a/common/djangoapps/enrollment/tests/test_api.py +++ b/common/djangoapps/enrollment/tests/test_api.py @@ -230,3 +230,52 @@ class EnrollmentTest(CacheIsolationTestCase): # The data matches self.assertEqual(len(details['course_modes']), 3) self.assertEqual(details, cached_details) + + def test_update_enrollment_expired_mode_with_error(self): + """ Verify that if verified mode is expired and include expire flag is + false then enrollment cannot be updated. """ + self.assert_add_modes_with_enrollment('audit') + # On updating enrollment mode to verified it should the raise the error. + with self.assertRaises(CourseModeNotFoundError): + self.assert_update_enrollment(mode='verified', include_expired=False) + + def test_update_enrollment_with_expired_mode(self): + """ Verify that if verified mode is expired then enrollment can be + updated if include_expired flag is true.""" + self.assert_add_modes_with_enrollment('audit') + # enrollment in verified mode will work fine with include_expired=True + self.assert_update_enrollment(mode='verified', include_expired=True) + + @ddt.data(True, False) + def test_unenroll_with_expired_mode(self, include_expired): + """ Verify that un-enroll will work fine for expired courses whether include_expired + is true or false.""" + self.assert_add_modes_with_enrollment('verified') + self.assert_update_enrollment(mode='verified', is_active=False, include_expired=include_expired) + + def assert_add_modes_with_enrollment(self, enrollment_mode): + """ Dry method for adding fake course enrollment information to fake + data API and enroll the student in the course. """ + fake_data_api.add_course(self.COURSE_ID, course_modes=['honor', 'verified', 'audit']) + result = api.add_enrollment(self.USERNAME, self.COURSE_ID, mode=enrollment_mode) + get_result = api.get_enrollment(self.USERNAME, self.COURSE_ID) + self.assertEquals(result, get_result) + # set the course verify mode as expire. + fake_data_api.set_expired_mode(self.COURSE_ID) + + def assert_update_enrollment(self, mode, is_active=True, include_expired=False): + """ Dry method for updating enrollment.""" + + result = api.update_enrollment( + self.USERNAME, self.COURSE_ID, mode=mode, is_active=is_active, include_expired=include_expired + ) + self.assertEquals(mode, result['mode']) + self.assertIsNotNone(result) + self.assertEquals(result['student'], self.USERNAME) + self.assertEquals(result['course']['course_id'], self.COURSE_ID) + self.assertEquals(result['mode'], mode) + + if is_active: + self.assertTrue(result['is_active']) + else: + self.assertFalse(result['is_active']) diff --git a/common/djangoapps/enrollment/tests/test_data.py b/common/djangoapps/enrollment/tests/test_data.py index f4ce7b001a..0ba3621d47 100644 --- a/common/djangoapps/enrollment/tests/test_data.py +++ b/common/djangoapps/enrollment/tests/test_data.py @@ -2,14 +2,17 @@ Test the Data Aggregation Layer for Course Enrollments. """ +import datetime +import unittest + import ddt from mock import patch from nose.tools import raises -import unittest - +from pytz import UTC from django.conf import settings -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory + +from course_modes.models import CourseMode +from enrollment import data from enrollment.errors import ( UserNotFoundError, CourseEnrollmentClosedError, CourseEnrollmentFullError, CourseEnrollmentExistsError, @@ -17,7 +20,8 @@ from enrollment.errors import ( from openedx.core.lib.exceptions import CourseNotFoundError from student.tests.factories import UserFactory, CourseModeFactory from student.models import CourseEnrollment, EnrollmentClosedError, CourseFullError, AlreadyEnrolledError -from enrollment import data +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory @ddt.ddt @@ -257,3 +261,34 @@ class EnrollmentDataTest(ModuleStoreTestCase): def test_update_for_non_existent_course(self): enrollment = data.update_course_enrollment(self.user.username, "some/fake/course", is_active=False) self.assertIsNone(enrollment) + + def test_get_course_with_expired_mode_included(self): + """Verify that method returns expired modes if include_expired + is true.""" + modes = ['honor', 'verified', 'audit'] + self._create_course_modes(modes, course=self.course) + self._update_verified_mode_as_expired(self.course.id) + self.assert_enrollment_modes(modes, True) + + def test_get_course_without_expired_mode_included(self): + """Verify that method does not returns expired modes if include_expired + is false.""" + self._create_course_modes(['honor', 'verified', 'audit'], course=self.course) + self._update_verified_mode_as_expired(self.course.id) + self.assert_enrollment_modes(['audit', 'honor'], False) + + def _update_verified_mode_as_expired(self, course_id): + """Dry method to change verified mode expiration.""" + mode = CourseMode.objects.get(course_id=course_id, mode_slug=CourseMode.VERIFIED) + mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=UTC) + mode.save() + + def assert_enrollment_modes(self, expected_modes, include_expired): + """Get enrollment data and assert response with expected modes.""" + result_course = data.get_course_enrollment_info(unicode(self.course.id), include_expired=include_expired) + result_slugs = [mode['slug'] for mode in result_course['course_modes']] + for course_mode in expected_modes: + self.assertIn(course_mode, result_slugs) + + if not include_expired: + self.assertNotIn('verified', result_slugs) diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py index 885028a129..b9e5a30340 100644 --- a/common/djangoapps/enrollment/tests/test_views.py +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -885,6 +885,37 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): self.assert_enrollment_status(username='fake-user', expected_status=status.HTTP_406_NOT_ACCEPTABLE, as_server=True) + def test_update_enrollment_with_expired_mode_throws_error(self): + """Verify that if verified mode is expired than it's enrollment cannot be updated. """ + for mode in [CourseMode.DEFAULT_MODE_SLUG, CourseMode.VERIFIED]: + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=mode, + mode_display_name=mode, + ) + + # Create an enrollment + self.assert_enrollment_status(as_server=True) + + # Check that the enrollment is the default. + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertTrue(is_active) + self.assertEqual(course_mode, CourseMode.DEFAULT_MODE_SLUG) + + # Change verified mode expiration. + mode = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) + mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=pytz.utc) + mode.save() + self.assert_enrollment_status( + as_server=True, + mode=CourseMode.VERIFIED, + expected_status=status.HTTP_400_BAD_REQUEST + ) + course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertTrue(is_active) + self.assertEqual(course_mode, CourseMode.DEFAULT_MODE_SLUG) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class EnrollmentEmbargoTest(EnrollmentTestMixin, UrlResetMixin, ModuleStoreTestCase): diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 27bfd959f8..9a9e773a92 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -161,7 +161,10 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase self.course = CourseFactory(display_name=u'teꜱᴛ') self.student = UserFactory.create(username='student', email='test@example.com', password='test') - for mode in (CourseMode.AUDIT, CourseMode.VERIFIED): + for mode in ( + CourseMode.AUDIT, CourseMode.PROFESSIONAL, CourseMode.CREDIT_MODE, + CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.VERIFIED, CourseMode.HONOR + ): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) # pylint: disable=no-member self.verification_deadline = VerificationDeadline( @@ -200,7 +203,8 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase 'verified_upgrade_deadline': None, }, data[0]) self.assertEqual( - {CourseMode.VERIFIED, CourseMode.AUDIT}, + {CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.HONOR, + CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL}, {mode['slug'] for mode in data[0]['course_modes']} ) @@ -252,11 +256,11 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase 'reason': '' }, r'User \w+ is not enrolled with mode ' + CourseMode.HONOR), ({ - 'course_id': None, + 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.CREDIT_MODE, - 'reason': '' - }, "Specified course mode '{}' unavailable".format(CourseMode.CREDIT_MODE)) + 'reason': 'Enrollment cannot be changed to credit mode' + }, '') ) @ddt.unpack def test_change_enrollment_bad_data(self, data, error_message): @@ -269,3 +273,104 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase self.assertIsNotNone(re.match(error_message, response.content)) self.assert_enrollment(CourseMode.AUDIT) self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) + + @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') + def test_update_enrollment_for_all_modes(self, new_mode): + """ Verify support can changed the enrollment to all available modes + except credit. """ + self.assert_update_enrollment('username', new_mode) + + @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') + def test_update_enrollment_for_ended_course(self, new_mode): + """ Verify support can changed the enrollment of archived course. """ + self.set_course_end_date_and_expiry() + self.assert_update_enrollment('username', new_mode) + + def test_update_enrollment_with_credit_mode_throws_error(self): + """ Verify that enrollment cannot be changed to credit mode. """ + self.assert_update_enrollment('username', CourseMode.CREDIT_MODE) + + @ddt.data('username', 'email') + def test_get_enrollments_with_expired_mode(self, search_string_type): + """ Verify that page can get the all modes with archived course. """ + self.set_course_end_date_and_expiry() + url = reverse( + 'support:enrollment_list', + kwargs={'username_or_email': getattr(self.student, search_string_type)} + ) + response = self.client.get(url) + self._assert_generated_modes(response) + + @ddt.data('username', 'email') + def test_update_enrollments_with_expired_mode(self, search_string_type): + """ Verify that enrollment can be updated to verified mode. """ + self.set_course_end_date_and_expiry() + self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) + self.assert_update_enrollment(search_string_type, CourseMode.VERIFIED) + + def _assert_generated_modes(self, response): + """Dry method to generate course modes dict and test with response data.""" + modes = CourseMode.modes_for_course(self.course.id, include_expired=True) # pylint: disable=no-member + modes_data = [] + for mode in modes: + expiry = mode.expiration_datetime.strftime('%Y-%m-%dT%H:%M:%SZ') if mode.expiration_datetime else None + modes_data.append({ + 'sku': mode.sku, + 'expiration_datetime': expiry, + 'name': mode.name, + 'currency': mode.currency, + 'bulk_sku': mode.bulk_sku, + 'min_price': mode.min_price, + 'suggested_prices': mode.suggested_prices, + 'slug': mode.slug, + 'description': mode.description + }) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertEqual(len(data), 1) + + self.assertEqual( + modes_data, + data[0]['course_modes'] + ) + + self.assertEqual( + {CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.NO_ID_PROFESSIONAL_MODE, + CourseMode.PROFESSIONAL, CourseMode.HONOR}, + {mode['slug'] for mode in data[0]['course_modes']} + ) + + def assert_update_enrollment(self, search_string_type, new_mode): + """ Dry method to update the enrollment and assert response.""" + self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) + url = reverse( + 'support:enrollment_list', + kwargs={'username_or_email': getattr(self.student, search_string_type)} + ) + response = self.client.post(url, data={ + 'course_id': unicode(self.course.id), # pylint: disable=no-member + 'old_mode': CourseMode.AUDIT, + 'new_mode': new_mode, + 'reason': 'Financial Assistance' + }) + # Enrollment cannot be changed to credit mode. + if new_mode == CourseMode.CREDIT_MODE: + self.assertEqual(response.status_code, 400) + else: + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) + self.assert_enrollment(new_mode) + + def set_course_end_date_and_expiry(self): + """ Set the course-end date and expire its verified mode.""" + self.course.start = datetime(year=1970, month=1, day=1, tzinfo=UTC) + self.course.end = datetime(year=1970, month=1, day=10, tzinfo=UTC) + + # change verified mode expiry. + verified_mode = CourseMode.objects.get( + course_id=self.course.id, # pylint: disable=no-member + mode_slug=CourseMode.VERIFIED + ) + verified_mode.expiration_datetime = datetime(year=1970, month=1, day=9, tzinfo=UTC) + verified_mode.save() diff --git a/lms/djangoapps/support/views/enrollments.py b/lms/djangoapps/support/views/enrollments.py index 46420606a1..1a2ef8dd32 100644 --- a/lms/djangoapps/support/views/enrollments.py +++ b/lms/djangoapps/support/views/enrollments.py @@ -16,6 +16,7 @@ from course_modes.models import CourseMode from edxmako.shortcuts import render_to_response from enrollment.api import get_enrollments, update_enrollment from enrollment.errors import CourseModeNotFoundError +from enrollment.serializers import ModeSerializer from lms.djangoapps.support.decorators import require_support_permission from lms.djangoapps.support.serializers import ManualEnrollmentSerializer from lms.djangoapps.verify_student.models import VerificationDeadline @@ -61,6 +62,8 @@ class EnrollmentSupportListView(GenericAPIView): # Folds the course_details field up into the main JSON object. enrollment.update(**enrollment.pop('course_details')) course_key = CourseKey.from_string(enrollment['course_id']) + # get the all courses modes and replace with existing modes. + enrollment['course_modes'] = self.get_course_modes(course_key) # Add the price of the course's verified mode. self.include_verified_mode_info(enrollment, course_key) # Add manual enrollment history, if it exists @@ -83,6 +86,8 @@ class EnrollmentSupportListView(GenericAPIView): username=user.username, old_mode=old_mode )) + if new_mode == CourseMode.CREDIT_MODE: + return HttpResponseBadRequest(u'Enrollment cannot be changed to credit mode.') except KeyError as err: return HttpResponseBadRequest(u'The field {} is required.'.format(err.message)) except InvalidKeyError: @@ -98,7 +103,7 @@ class EnrollmentSupportListView(GenericAPIView): # Wrapped in a transaction so that we can be sure the # ManualEnrollmentAudit record is always created correctly. with transaction.atomic(): - update_enrollment(user.username, course_id, mode=new_mode) + update_enrollment(user.username, course_id, mode=new_mode, include_expired=True) manual_enrollment = ManualEnrollmentAudit.create_manual_enrollment_audit( request.user, enrollment.user.email, @@ -150,3 +155,24 @@ class EnrollmentSupportListView(GenericAPIView): if manual_enrollment_audit is None: return {} return ManualEnrollmentSerializer(instance=manual_enrollment_audit).data + + @staticmethod + def get_course_modes(course_key): + """ + Returns a list of all modes including expired modes for a given course id + + Arguments: + course_id (CourseKey): Search for course modes for this course. + + Returns: + list of `Mode` + + """ + course_modes = CourseMode.modes_for_course( + course_key, + include_expired=True + ) + return [ + ModeSerializer(mode).data + for mode in course_modes + ]