From 4bab316bb9dca744572ded165fc731862efe3302 Mon Sep 17 00:00:00 2001 From: Awais Date: Sat, 7 Mar 2015 00:17:52 +0500 Subject: [PATCH] ECOM-911 no-id-professional mode registration flow --- common/djangoapps/course_modes/admin.py | 3 +- common/djangoapps/course_modes/models.py | 195 ++++++++++++++++-- .../course_modes/tests/test_models.py | 114 ++++++++++ .../course_modes/tests/test_views.py | 29 ++- common/djangoapps/course_modes/views.py | 8 +- .../djangoapps/enrollment/tests/test_api.py | 6 +- common/djangoapps/student/models.py | 14 +- .../student/tests/test_enrollment.py | 4 + common/djangoapps/student/tests/tests.py | 7 +- common/djangoapps/student/views.py | 4 +- lms/djangoapps/shoppingcart/models.py | 28 ++- .../shoppingcart/tests/test_models.py | 23 +++ .../student_account/test/test_views.py | 2 +- .../verify_student/tests/test_views.py | 76 ++++++- lms/djangoapps/verify_student/views.py | 84 +++++--- .../dashboard/_dashboard_course_listing.html | 61 ++---- 16 files changed, 526 insertions(+), 132 deletions(-) diff --git a/common/djangoapps/course_modes/admin.py b/common/djangoapps/course_modes/admin.py index 255b2ab706..cfc183ecf4 100644 --- a/common/djangoapps/course_modes/admin.py +++ b/common/djangoapps/course_modes/admin.py @@ -21,7 +21,8 @@ class CourseModeForm(forms.ModelForm): COURSE_MODE_SLUG_CHOICES = ( [(CourseMode.DEFAULT_MODE_SLUG, CourseMode.DEFAULT_MODE_SLUG)] + - [(mode_slug, mode_slug) for mode_slug in CourseMode.VERIFIED_MODES] + [(mode_slug, mode_slug) for mode_slug in CourseMode.VERIFIED_MODES] + + [(CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.NO_ID_PROFESSIONAL_MODE)] ) mode_slug = forms.ChoiceField(choices=COURSE_MODE_SLUG_CHOICES) diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index db036457c3..4df45380e4 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -65,11 +65,17 @@ class CourseMode(models.Model): help_text="This is the SKU(stock keeping unit) of this mode in external services." ) - DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd', None, None, None) - DEFAULT_MODE_SLUG = 'honor' + HONOR = 'honor' + PROFESSIONAL = 'professional' + VERIFIED = "verified" + AUDIT = "audit" + NO_ID_PROFESSIONAL_MODE = "no-id-professional" + + DEFAULT_MODE = Mode(HONOR, _('Honor Code Certificate'), 0, '', 'usd', None, None, None) + DEFAULT_MODE_SLUG = HONOR # Modes that allow a student to pursue a verified certificate - VERIFIED_MODES = ["verified", "professional"] + VERIFIED_MODES = [VERIFIED, PROFESSIONAL] class Meta: """ meta attributes of this model """ @@ -248,6 +254,23 @@ class CourseMode(models.Model): # we prefer professional over verify return professional_mode if professional_mode else verified_mode + @classmethod + def min_course_price_for_verified_for_currency(cls, course_id, currency): # pylint: disable=invalid-name + """ + Returns the minimum price of the course int he appropriate currency over all the + course's *verified*, non-expired modes. + + Assuming all verified courses have a minimum price of >0, this value should always + be >0. + + If no verified mode is found, 0 is returned. + """ + modes = cls.modes_for_course(course_id) + for mode in modes: + if (mode.currency == currency) and (mode.slug == 'verified'): + return mode.min_price + return 0 + @classmethod def has_verified_mode(cls, course_mode_dict): """Check whether the modes for a course allow a student to pursue a verfied certificate. @@ -265,21 +288,65 @@ class CourseMode(models.Model): return False @classmethod - def min_course_price_for_verified_for_currency(cls, course_id, currency): + def has_professional_mode(cls, modes_dict): """ - Returns the minimum price of the course int he appropriate currency over all the - course's *verified*, non-expired modes. + check the course mode is profession or no-id-professional - Assuming all verified courses have a minimum price of >0, this value should always - be >0. + Args: + modes_dict (dict): course modes. - If no verified mode is found, 0 is returned. + Returns: + bool """ - modes = cls.modes_for_course(course_id) - for mode in modes: - if (mode.currency == currency) and (mode.slug == 'verified'): - return mode.min_price - return 0 + return cls.PROFESSIONAL in modes_dict or cls.NO_ID_PROFESSIONAL_MODE in modes_dict + + @classmethod + def is_professional_mode(cls, course_mode_tuple): + """ + checking that tuple is professional mode. + Args: + course_mode_tuple (tuple) : course mode tuple + + Returns: + bool + """ + return course_mode_tuple.slug in [cls.PROFESSIONAL, cls.NO_ID_PROFESSIONAL_MODE] if course_mode_tuple else False + + @classmethod + def is_professional_slug(cls, slug): + """checking slug is professional + Args: + slug (str) : course mode string + Return: + bool + """ + return slug in [cls.PROFESSIONAL, cls.NO_ID_PROFESSIONAL_MODE] + + @classmethod + def is_verified_mode(cls, course_mode_tuple): + """Check whether the given modes is_verified or not. + + Args: + course_mode_tuple(Mode): Mode tuple + + Returns: + bool: True iff the course modes is verified else False. + + """ + return course_mode_tuple.slug in cls.VERIFIED_MODES + + @classmethod + def is_verified_slug(cls, mode_slug): + """Check whether the given mode_slug is_verified or not. + + Args: + mode_slug(str): Mode Slug + + Returns: + bool: True iff the course mode slug is verified else False. + + """ + return mode_slug in cls.VERIFIED_MODES @classmethod def has_payment_options(cls, course_id): @@ -325,8 +392,8 @@ class CourseMode(models.Model): if modes_dict is None: modes_dict = cls.modes_for_course_dict(course_id) - # Professional mode courses are always behind a paywall - if "professional" in modes_dict: + # Professional and no-id-professional mode courses are always behind a paywall + if cls.has_professional_mode(modes_dict): return False # White-label uses course mode honor with a price @@ -335,7 +402,7 @@ class CourseMode(models.Model): return False # Check that the default mode is available. - return ("honor" in modes_dict) + return (cls.HONOR in modes_dict) @classmethod def is_white_label(cls, course_id, modes_dict=None): @@ -360,13 +427,13 @@ class CourseMode(models.Model): # White-label uses course mode honor with a price # to indicate that the course is behind a paywall. - if "honor" in modes_dict and len(modes_dict) == 1: + if cls.HONOR in modes_dict and len(modes_dict) == 1: if modes_dict["honor"].min_price > 0 or modes_dict["honor"].suggested_prices != '': return True return False @classmethod - def min_course_price_for_currency(cls, course_id, currency): + def min_course_price_for_currency(cls, course_id, currency): # pylint: disable=invalid-name """ Returns the minimum price of the course in the appropriate currency over all the course's non-expired modes. @@ -375,6 +442,96 @@ class CourseMode(models.Model): modes = cls.modes_for_course(course_id) return min(mode.min_price for mode in modes if mode.currency == currency) + @classmethod + def enrollment_mode_display(cls, mode, verification_status): + """ Select appropriate display strings and CSS classes. + + Uses mode and verification status to select appropriate display strings and CSS classes + for certificate display. + + Args: + mode (str): enrollment mode. + verification_status (str) : verification status of student + + Returns: + dictionary: + """ + + # import inside the function to avoid the circular import + from student.helpers import ( + VERIFY_STATUS_NEED_TO_VERIFY, + VERIFY_STATUS_SUBMITTED, + VERIFY_STATUS_APPROVED + ) + + show_image = False + image_alt = '' + + if mode == cls.VERIFIED: + if verification_status in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED]: + enrollment_title = _("Your verification is pending") + enrollment_value = _("Verified: Pending Verification") + show_image = True + image_alt = _("ID verification pending") + elif verification_status == VERIFY_STATUS_APPROVED: + enrollment_title = _("You're enrolled as a verified student") + enrollment_value = _("Verified") + show_image = True + image_alt = _("ID Verified Ribbon/Badge") + else: + enrollment_title = _("You're enrolled as an honor code student") + enrollment_value = _("Honor Code") + elif mode == cls.HONOR: + enrollment_title = _("You're enrolled as an honor code student") + enrollment_value = _("Honor Code") + elif mode == cls.AUDIT: + enrollment_title = _("You're auditing this course") + enrollment_value = _("Auditing") + elif mode in [cls.PROFESSIONAL, cls.NO_ID_PROFESSIONAL_MODE]: + enrollment_title = _("You're enrolled as a professional education student") + enrollment_value = _("Professional Ed") + else: + enrollment_title = '' + enrollment_value = '' + + return { + 'enrollment_title': unicode(enrollment_title), + 'enrollment_value': unicode(enrollment_value), + 'show_image': show_image, + 'image_alt': unicode(image_alt), + 'display_mode': cls._enrollment_mode_display(mode, verification_status) + } + + @staticmethod + def _enrollment_mode_display(enrollment_mode, verification_status): + """Checking enrollment mode and status and returns the display mode + Args: + enrollment_mode (str): enrollment mode. + verification_status (str) : verification status of student + + Returns: + display_mode (str) : display mode for certs + """ + + # import inside the function to avoid the circular import + from student.helpers import ( + VERIFY_STATUS_NEED_TO_VERIFY, + VERIFY_STATUS_SUBMITTED, + VERIFY_STATUS_APPROVED + ) + + if enrollment_mode == CourseMode.VERIFIED: + if verification_status in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED]: + display_mode = "verified" + else: + display_mode = "honor" + elif enrollment_mode in [CourseMode.PROFESSIONAL, CourseMode.NO_ID_PROFESSIONAL_MODE]: + display_mode = "professional" + else: + display_mode = enrollment_mode + + return display_mode + def to_tuple(self): """ Takes a mode model and turns it into a model named tuple. diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index ff19119c7f..17f7758e77 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -150,11 +150,17 @@ class CourseModeModelTest(TestCase): honor.save() self.assertTrue(CourseMode.has_payment_options(self.course_key)) + def test_course_has_payment_options_with_no_id_professional(self): + # Has payment options. + self.create_mode('no-id-professional', 'no-id-professional', min_price=5) + self.assertTrue(CourseMode.has_payment_options(self.course_key)) + @ddt.data( ([], True), ([("honor", 0), ("audit", 0), ("verified", 100)], True), ([("honor", 100)], False), ([("professional", 100)], False), + ([("no-id-professional", 100)], False), ) @ddt.unpack def test_can_auto_enroll(self, modes_and_prices, can_auto_enroll): @@ -206,3 +212,111 @@ class CourseModeModelTest(TestCase): # Check that we get a default mode for when no course mode is available self.assertEqual(len(all_modes[other_course_key]), 1) self.assertEqual(all_modes[other_course_key][0], CourseMode.DEFAULT_MODE) + + @ddt.data('', 'no-id-professional', 'professional', 'verified') + def test_course_has_professional_mode(self, mode): + # check the professional mode. + + self.create_mode(mode, 'course mode', 10) + modes_dict = CourseMode.modes_for_course_dict(self.course_key) + + if mode in ['professional', 'no-id-professional']: + self.assertTrue(CourseMode.has_professional_mode(modes_dict)) + else: + self.assertFalse(CourseMode.has_professional_mode(modes_dict)) + + @ddt.data('no-id-professional', 'professional', 'verified') + def test_course_is_professional_mode(self, mode): + # check that tuple has professional mode + + course_mode, __ = self.create_mode(mode, 'course mode', 10) + if mode in ['professional', 'no-id-professional']: + self.assertTrue(CourseMode.is_professional_mode(course_mode.to_tuple())) + else: + self.assertFalse(CourseMode.is_professional_mode(course_mode.to_tuple())) + + def test_course_is_professional_mode_with_invalid_tuple(self): + # check that tuple has professional mode with None + self.assertFalse(CourseMode.is_professional_mode(None)) + + @ddt.data( + ('no-id-professional', False), + ('professional', True), + ('verified', True), + ('honor', False), + ('audit', False) + ) + @ddt.unpack + def test_is_verified_slug(self, mode_slug, is_verified): + # check that mode slug is verified or not + if is_verified: + self.assertTrue(CourseMode.is_verified_slug(mode_slug)) + else: + self.assertFalse(CourseMode.is_verified_slug(mode_slug)) + + @ddt.data( + ("verified", "verify_need_to_verify"), + ("verified", "verify_submitted"), + ("verified", "verify_approved"), + ("verified", 'dummy'), + ("verified", None), + ('honor', None), + ('honor', 'dummy'), + ('audit', None), + ('professional', None), + ('no-id-professional', None), + ('no-id-professional', 'dummy') + ) + @ddt.unpack + def test_enrollment_mode_display(self, mode, verification_status): + if mode == "verified": + self.assertEqual( + CourseMode.enrollment_mode_display(mode, verification_status), + self._enrollment_display_modes_dicts(verification_status) + ) + self.assertEqual( + CourseMode.enrollment_mode_display(mode, verification_status), + self._enrollment_display_modes_dicts(verification_status) + ) + self.assertEqual( + CourseMode.enrollment_mode_display(mode, verification_status), + self._enrollment_display_modes_dicts(verification_status) + ) + elif mode == "honor": + self.assertEqual( + CourseMode.enrollment_mode_display(mode, verification_status), + self._enrollment_display_modes_dicts(mode) + ) + elif mode == "audit": + self.assertEqual( + CourseMode.enrollment_mode_display(mode, verification_status), + self._enrollment_display_modes_dicts(mode) + ) + elif mode == "professional": + self.assertEqual( + CourseMode.enrollment_mode_display(mode, verification_status), + self._enrollment_display_modes_dicts(mode) + ) + + def _enrollment_display_modes_dicts(self, dict_type): + """ + Helper function to generate the enrollment display mode dict. + """ + dict_keys = ['enrollment_title', 'enrollment_value', 'show_image', 'image_alt', 'display_mode'] + display_values = { + "verify_need_to_verify": ["Your verification is pending", "Verified: Pending Verification", True, + 'ID verification pending', 'verified'], + "verify_approved": ["You're enrolled as a verified student", "Verified", True, 'ID Verified Ribbon/Badge', + 'verified'], + "verify_none": ["You're enrolled as an honor code student", "Honor Code", False, '', 'honor'], + "honor": ["You're enrolled as an honor code student", "Honor Code", False, '', 'honor'], + "audit": ["You're auditing this course", "Auditing", False, '', 'audit'], + "professional": ["You're enrolled as a professional education student", "Professional Ed", False, '', + 'professional'] + } + if dict_type in ['verify_need_to_verify', 'verify_submitted']: + return dict(zip(dict_keys, display_values.get('verify_need_to_verify'))) + elif dict_type is None or dict_type == 'dummy': + return dict(zip(dict_keys, display_values.get('verify_none'))) + else: + return dict(zip(dict_keys, display_values.get(dict_type))) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index aa53f487ae..ff87a2ec42 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -68,6 +68,25 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): else: self.assertEquals(response.status_code, 200) + def test_no_id_redirect(self): + # Create the course modes + CourseModeFactory(mode_slug=CourseMode.NO_ID_PROFESSIONAL_MODE, course_id=self.course.id, min_price=100) + + # Enroll the user in the test course + CourseEnrollmentFactory( + is_active=False, + mode=CourseMode.NO_ID_PROFESSIONAL_MODE, + course_id=self.course.id, + user=self.user + ) + + # Configure whether we're upgrading or not + url = reverse('course_modes_choose', args=[unicode(self.course.id)]) + response = self.client.get(url) + # Check whether we were correctly redirected + start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) + self.assertRedirects(response, start_flow_url) + def test_no_enrollment(self): # Create the course modes for mode in ('audit', 'honor', 'verified'): @@ -115,9 +134,10 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): # TODO: Fix it so that response.templates works w/ mako templates, and then assert # that the right template rendered - def test_professional_enrollment(self): + @ddt.data('professional', 'no-id-professional') + def test_professional_enrollment(self, mode): # The only course mode is professional ed - CourseModeFactory(mode_slug='professional', course_id=self.course.id) + CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=1) # Go to the "choose your track" page choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)]) @@ -132,7 +152,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): CourseEnrollmentFactory( user=self.user, is_active=True, - mode="professional", + mode=mode, course_id=unicode(self.course.id), ) @@ -156,7 +176,8 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): def test_choose_mode_redirect(self, course_mode, expected_redirect): # Create the course modes for mode in ('audit', 'honor', 'verified'): - CourseModeFactory(mode_slug=mode, course_id=self.course.id) + min_price = 0 if course_mode in ["honor", "audit"] else 1 + CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price) # Choose the mode (POST request) choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)]) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index e20542d6b9..ffa009ee78 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -72,9 +72,9 @@ class ChooseModeView(View): # We assume that, if 'professional' is one of the modes, it is the *only* mode. # If we offer more modes alongside 'professional' in the future, this will need to route - # to the usual "choose your track" page. - has_enrolled_professional = (enrollment_mode == "professional" and is_active) - if "professional" in modes and not has_enrolled_professional: + # to the usual "choose your track" page same is true for no-id-professional mode. + has_enrolled_professional = (CourseMode.is_professional_slug(enrollment_mode) and is_active) + if CourseMode.has_professional_mode(modes) and not has_enrolled_professional: return redirect( reverse( 'verify_student_start_flow', @@ -90,7 +90,7 @@ class ChooseModeView(View): return redirect(reverse('dashboard')) # If a user has already paid, redirect them to the dashboard. - if is_active and enrollment_mode in CourseMode.VERIFIED_MODES: + if is_active and (enrollment_mode in CourseMode.VERIFIED_MODES + [CourseMode.NO_ID_PROFESSIONAL_MODE]): return redirect(reverse('dashboard')) donation_for_course = request.session.get("donation_for_course", {}) diff --git a/common/djangoapps/enrollment/tests/test_api.py b/common/djangoapps/enrollment/tests/test_api.py index c165a5d627..d2bfafc2dc 100644 --- a/common/djangoapps/enrollment/tests/test_api.py +++ b/common/djangoapps/enrollment/tests/test_api.py @@ -38,7 +38,8 @@ class EnrollmentTest(TestCase): (['honor', 'verified', 'audit'], 'honor'), # Check for professional ed happy path. - (['professional'], 'professional') + (['professional'], 'professional'), + (['no-id-professional'], 'no-id-professional') ) @ddt.unpack def test_enroll(self, course_modes, mode): @@ -72,7 +73,8 @@ class EnrollmentTest(TestCase): (['honor', 'verified', 'audit'], 'honor'), # Check for professional ed happy path. - (['professional'], 'professional') + (['professional'], 'professional'), + (['no-id-professional'], 'no-id-professional') ) @ddt.unpack def test_unenroll(self, course_modes, mode): diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 172e88fabc..5af95aea51 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1100,9 +1100,8 @@ class CourseEnrollment(models.Model): """ Returns True, if course is paid """ - paid_course = CourseMode.objects.filter(Q(course_id=self.course_id) & Q(mode_slug='honor') & - (Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gte=datetime.now(pytz.UTC)))).exclude(min_price=0) - if paid_course or self.mode == 'professional': + paid_course = CourseMode.is_white_label(self.course_id) + if paid_course or CourseMode.is_professional_slug(self.mode): return True return False @@ -1154,6 +1153,12 @@ class CourseEnrollment(models.Model): def course(self): return modulestore().get_course(self.course_id) + def is_verified_enrollment(self): + """ + Check the course enrollment mode is verified or not + """ + return CourseMode.is_verified_slug(self.mode) + class CourseEnrollmentAllowed(models.Model): """ @@ -1403,6 +1408,9 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): "honor": ugettext_lazy(u"{platform_name} Honor Code Certificate for {course_name}"), "verified": ugettext_lazy(u"{platform_name} Verified Certificate for {course_name}"), "professional": ugettext_lazy(u"{platform_name} Professional Certificate for {course_name}"), + "no-id-professional": ugettext_lazy( + u"{platform_name} Professional Certificate for {course_name}" + ), } company_identifier = models.TextField( diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index 6bd38ed934..3c784446ab 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -55,6 +55,7 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase): # We should NOT be auto-enrolled, because that would be giving # away an expensive course for free :) (['professional'], 'course_modes_choose', None), + (['no-id-professional'], 'course_modes_choose', None), ) @ddt.unpack def test_enroll(self, course_modes, next_url, enrollment_mode): @@ -113,6 +114,9 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase): (['professional'], 'true'), (['professional'], 'false'), (['professional'], None), + (['no-id-professional'], 'true'), + (['no-id-professional'], 'false'), + (['no-id-professional'], None), ) @ddt.unpack def test_enroll_with_email_opt_in(self, course_modes, email_opt_in, mock_update_email_opt_in): diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index bedf147b58..81363b87c0 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -219,7 +219,10 @@ class DashboardTest(ModuleStoreTestCase): attempt.approve() response = self.client.get(reverse('dashboard')) - self.assertContains(response, "class=\"course {0}\"".format(mode)) + if mode in ['professional', 'no-id-professional']: + self.assertContains(response, 'class="course professional"') + else: + self.assertContains(response, 'class="course {0}"'.format(mode)) self.assertContains(response, value) @patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': True}) @@ -231,6 +234,8 @@ class DashboardTest(ModuleStoreTestCase): self._check_verification_status_on('verified', 'You\'re enrolled as a verified student') self._check_verification_status_on('honor', 'You\'re enrolled as an honor code student') self._check_verification_status_on('audit', 'You\'re auditing this course') + self._check_verification_status_on('professional', 'You\'re enrolled as a professional education student') + self._check_verification_status_on('no-id-professional', 'You\'re enrolled as a professional education student') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def _check_verification_status_off(self, mode, value): diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ba3ea82ae6..4521d9fb00 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -909,9 +909,9 @@ def change_enrollment(request, check_access=True): # If we have more than one course mode or professional ed is enabled, # then send the user to the choose your track page. - # (In the case of professional ed, this will redirect to a page that + # (In the case of no-id-professional/professional ed, this will redirect to a page that # funnels users directly into the verification / payment flow) - if CourseMode.has_verified_mode(available_modes): + if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes): return HttpResponse( reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)}) ) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 8b0c1c7268..432bd80a85 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1746,20 +1746,26 @@ class CertificateItem(OrderItem): self.course_enrollment.activate() def additional_instruction_text(self): + verification_reminder = "" + is_enrollment_mode_verified = self.course_enrollment.is_verified_enrollment() # pylint: disable=E1101 + + if is_enrollment_mode_verified: + domain = microsite.get_value('SITE_NAME', settings.SITE_NAME) + path = reverse('verify_student_verify_later', kwargs={'course_id': unicode(self.course_id)}) + verification_url = "http://{domain}{path}".format(domain=domain, path=path) + + verification_reminder = _( + "If you haven't verified your identity yet, please start the verification process ({verification_url})." + ).format(verification_url=verification_url) + refund_reminder = _( - "You have up to two weeks into the course to unenroll from the Verified Certificate option " - "and receive a full refund. To receive your refund, contact {billing_email}. " + "You have up to two weeks into the course to unenroll and receive a full refund." + "To receive your refund, contact {billing_email}. " "Please include your order number in your email. " "Please do NOT include your credit card information." - ).format(billing_email=settings.PAYMENT_SUPPORT_EMAIL) - - domain = microsite.get_value('SITE_NAME', settings.SITE_NAME) - path = reverse('verify_student_verify_later', kwargs={'course_id': unicode(self.course_id)}) - verification_url = "http://{domain}{path}".format(domain=domain, path=path) - - verification_reminder = _( - "If you haven't verified your identity yet, please start the verification process ({verification_url})." - ).format(verification_url=verification_url) + ).format( + billing_email=settings.PAYMENT_SUPPORT_EMAIL + ) # Need this to be unicode in case the reminder strings # have been translated and contain non-ASCII unicode diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index bc2f9ae2f0..7fe3044fb0 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -801,6 +801,29 @@ class CertificateItemTest(ModuleStoreTestCase): ret_val = CourseEnrollment.unenroll(self.user, self.course_key) self.assertFalse(ret_val) + def test_no_id_prof_confirm_email(self): + # Pay for a no-id-professional course + course_mode = CourseMode(course_id=self.course_key, + mode_slug="no-id-professional", + mode_display_name="No Id Professional Cert", + min_price=self.cost) + course_mode.save() + CourseEnrollment.enroll(self.user, self.course_key) + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_key, self.cost, 'no-id-professional') + # verify that we are still enrolled + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) + self.mock_tracker.reset_mock() + cart.purchase() + enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key) + self.assertEquals(enrollment.mode, u'no-id-professional') + + # Check that the tax-deduction information appears in the confirmation email + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEquals('Order Payment Confirmation', email.subject) + self.assertNotIn("If you haven't verified your identity yet, please start the verification process", email.body) + class DonationTest(ModuleStoreTestCase): """Tests for the donation order item type. """ diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 4e947446ae..a3897e806f 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -298,7 +298,7 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) ] self._assert_third_party_auth_data(response, current_provider, expected_providers) - @ddt.data([], ["honor"], ["honor", "verified", "audit"], ["professional"]) + @ddt.data([], ["honor"], ["honor", "verified", "audit"], ["professional"], ["no-id-professional"]) def test_third_party_auth_course_id_verified(self, modes): # Create a course with the specified course modes course = CourseFactory.create() diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index eac86ff3bc..31f3e2c47a 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -98,6 +98,21 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): ]) self._assert_upgrade_session_flag(False) + @ddt.data("no-id-professional") + def test_start_flow_with_no_id_professional(self, course_mode): + course = self._create_course(course_mode) + # by default enrollment is honor + self._enroll(course.id, "honor") + response = self._get_page('verify_student_start_flow', course.id) + self._assert_displayed_mode(response, course_mode) + self._assert_steps_displayed( + response, + PayAndVerifyView.PAYMENT_STEPS, + PayAndVerifyView.MAKE_PAYMENT_STEP + ) + self._assert_messaging(response, PayAndVerifyView.FIRST_TIME_VERIFY_MSG) + self._assert_requirements_displayed(response, []) + @ddt.data("expired", "denied") def test_start_flow_expired_or_denied_verification(self, verification_status): course = self._create_course("verified") @@ -121,7 +136,8 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): ("verified", "submitted"), ("verified", "approved"), ("verified", "error"), - ("professional", "submitted") + ("professional", "submitted"), + ("no-id-professional", None), ) @ddt.unpack def test_start_flow_already_verified(self, course_mode, verification_status): @@ -516,6 +532,14 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): expected_status_code=404 ) + @ddt.data([], ["no-id-professional", "professional"], ["honor", "audit"]) + def test_no_id_professional_entry_point(self, modes_available): + course = self._create_course(*modes_available) + if "no-id-professional" in modes_available or "professional" in modes_available: + self._get_page("verify_student_start_flow", course.id, expected_status_code=200) + else: + self._get_page("verify_student_start_flow", course.id, expected_status_code=404) + @ddt.data( "verify_student_start_flow", "verify_student_verify_now", @@ -647,7 +671,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): modulestore().update_item(course, ModuleStoreEnum.UserID.test) for course_mode in course_modes: - min_price = (self.MIN_PRICE if course_mode != "honor" else 0) + min_price = (0 if course_mode in ["honor", "audit"] else self.MIN_PRICE) CourseModeFactory( course_id=course.id, mode_slug=course_mode, @@ -826,8 +850,8 @@ class TestCreateOrder(ModuleStoreTestCase): self.user = UserFactory.create(username="test", password="test") self.course = CourseFactory.create() - for mode in ('audit', 'honor', 'verified'): - CourseModeFactory(mode_slug=mode, course_id=self.course.id) + for mode, min_price in (('audit', 0), ('honor', 0), ('verified', 100)): + CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price) self.client.login(username="test", password="test") def test_create_order_already_verified(self): @@ -838,6 +862,7 @@ class TestCreateOrder(ModuleStoreTestCase): url = reverse('verify_student_create_order') params = { 'course_id': unicode(self.course.id), + 'contribution': 100 } response = self.client.post(url, params) self.assertEqual(response.status_code, 200) @@ -857,7 +882,7 @@ class TestCreateOrder(ModuleStoreTestCase): # Create a prof ed course course = CourseFactory.create() - CourseModeFactory(mode_slug="professional", course_id=course.id) + CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10) # Create an order for a prof ed course url = reverse('verify_student_create_order') @@ -872,6 +897,45 @@ class TestCreateOrder(ModuleStoreTestCase): self.assertEqual(data['merchant_defined_data1'], unicode(course.id)) self.assertEqual(data['merchant_defined_data2'], "professional") + def test_create_order_for_no_id_professional(self): + + # Create a no-id-professional ed course + course = CourseFactory.create() + CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10) + + # Create an order for a prof ed course + url = reverse('verify_student_create_order') + params = { + 'course_id': unicode(course.id) + } + response = self.client.post(url, params) + self.assertEqual(response.status_code, 200) + + # Verify that the course ID and transaction type are included in "merchant-defined data" + data = json.loads(response.content) + self.assertEqual(data['merchant_defined_data1'], unicode(course.id)) + self.assertEqual(data['merchant_defined_data2'], "no-id-professional") + + def test_create_order_for_multiple_paid_modes(self): + + # Create a no-id-professional ed course + course = CourseFactory.create() + CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10) + CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10) + + # Create an order for a prof ed course + url = reverse('verify_student_create_order') + params = { + 'course_id': unicode(course.id) + } + response = self.client.post(url, params) + self.assertEqual(response.status_code, 200) + + # Verify that the course ID and transaction type are included in "merchant-defined data" + data = json.loads(response.content) + self.assertEqual(data['merchant_defined_data1'], unicode(course.id)) + self.assertEqual(data['merchant_defined_data2'], "no-id-professional") + def test_create_order_set_donation_amount(self): # Verify the student so we don't need to submit photos self._verify_student() @@ -959,7 +1023,7 @@ class TestCreateOrderView(ModuleStoreTestCase): photo_id_image=self.IMAGE_DATA, expect_status_code=400 ) - self.assertIn('This course doesn\'t support verified certificates', response.content) + self.assertIn('This course doesn\'t support paid certificates', response.content) @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) def test_create_order_fail_with_get(self): diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 3a0a9d8ae2..1ec6931b89 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -272,15 +272,16 @@ class PayAndVerifyView(View): if redirect_url: return redirect(redirect_url) - # Check that the course has an unexpired verified mode - course_mode, expired_course_mode = self._get_verified_modes_for_course(course_key) + expired_verified_course_mode, unexpired_paid_course_mode = self._get_expired_verified_and_paid_mode(course_key) - if course_mode is not None: - log.info( - u"Entering verified workflow for user '%s', course '%s', with current step '%s'.", - request.user.id, course_id, current_step - ) - elif expired_course_mode is not None: + # Check that the course has an unexpired paid mode + if unexpired_paid_course_mode is not None: + if CourseMode.is_verified_mode(unexpired_paid_course_mode): + log.info( + u"Entering verified workflow for user '%s', course '%s', with current step '%s'.", + request.user.id, course_id, current_step + ) + elif expired_verified_course_mode is not None: # Check if there is an *expired* verified course mode; # if so, we should show a message explaining that the verification # deadline has passed. @@ -288,16 +289,16 @@ class PayAndVerifyView(View): context = { 'course': course, 'deadline': ( - get_default_time_display(expired_course_mode.expiration_datetime) - if expired_course_mode.expiration_datetime else "" + get_default_time_display(expired_verified_course_mode.expiration_datetime) + if expired_verified_course_mode.expiration_datetime else "" ) } return render_to_response("verify_student/missed_verification_deadline.html", context) else: - # Otherwise, there has never been a verified mode, + # Otherwise, there has never been a verified/paid mode, # so return a page not found response. log.warn( - u"No verified course mode found for course '%s' for verification flow request", + u"No paid/verified course mode found for course '%s' for verification/payment flow request", course_id ) raise Http404 @@ -307,7 +308,9 @@ class PayAndVerifyView(View): # with a paid course mode (such as "verified"). # For this reason, every paid user is enrolled, but not # every enrolled user is paid. - already_verified = self._check_already_verified(request.user) + # If the course mode is not verified(i.e only paid) then already_verified is always True + already_verified = self._check_already_verified(request.user) \ + if CourseMode.is_verified_mode(unexpired_paid_course_mode) else True already_paid, is_enrolled = self._check_enrollment(request.user, course_key) # Redirect the user to a more appropriate page if the @@ -326,7 +329,8 @@ class PayAndVerifyView(View): display_steps = self._display_steps( always_show_payment, already_verified, - already_paid + already_paid, + unexpired_paid_course_mode ) requirements = self._requirements(display_steps, request.user.is_active) @@ -371,7 +375,7 @@ class PayAndVerifyView(View): 'contribution_amount': contribution_amount, 'course': course, 'course_key': unicode(course_key), - 'course_mode': course_mode, + 'course_mode': unexpired_paid_course_mode, 'courseware_url': courseware_url, 'current_step': current_step, 'disable_courseware_js': True, @@ -383,8 +387,8 @@ class PayAndVerifyView(View): 'requirements': requirements, 'user_full_name': full_name, 'verification_deadline': ( - get_default_time_display(course_mode.expiration_datetime) - if course_mode.expiration_datetime else "" + get_default_time_display(unexpired_paid_course_mode.expiration_datetime) + if unexpired_paid_course_mode.expiration_datetime else "" ), } return render_to_response("verify_student/pay_and_verify.html", context) @@ -459,22 +463,33 @@ class PayAndVerifyView(View): if url is not None: return redirect(url) - def _get_verified_modes_for_course(self, course_key): - """Retrieve unexpired and expired verified modes for a course. + def _get_expired_verified_and_paid_mode(self, course_key): # pylint: disable=invalid-name + """Retrieve expired verified mode and unexpired paid mode(with min_price>0) for a course. Arguments: course_key (CourseKey): The location of the course. Returns: - Tuple of `(verified_mode, expired_verified_mode)`. If provided, - `verified_mode` is an *unexpired* verified mode for the course. - If provided, `expired_verified_mode` is an *expired* verified + Tuple of `(expired_verified_mode, unexpired_paid_mode)`. If provided, + `expired_verified_mode` is an *expired* verified mode for the course. + If provided, `unexpired_paid_mode` is an *unexpired* paid(with min_price>0) mode for the course. Either of these may be None. """ # Retrieve all the modes at once to reduce the number of database queries all_modes, unexpired_modes = CourseMode.all_and_unexpired_modes_for_courses([course_key]) + # Unexpired paid modes + unexpired_paid_modes = [mode for mode in unexpired_modes[course_key] if mode.min_price] + if len(unexpired_paid_modes) > 1: + # There is more than one paid mode defined, + # so choose the first one. + log.warn( + u"More than one paid modes are defined for course '%s' choosing the first one %s", + course_key, unexpired_paid_modes[0] + ) + unexpired_paid_mode = unexpired_paid_modes[0] if unexpired_paid_modes else None + # Find an unexpired verified mode verified_mode = CourseMode.verified_mode_for_course(course_key, modes=unexpired_modes[course_key]) expired_verified_mode = None @@ -482,9 +497,9 @@ class PayAndVerifyView(View): if verified_mode is None: expired_verified_mode = CourseMode.verified_mode_for_course(course_key, modes=all_modes[course_key]) - return (verified_mode, expired_verified_mode) + return (expired_verified_mode, unexpired_paid_mode) - def _display_steps(self, always_show_payment, already_verified, already_paid): + def _display_steps(self, always_show_payment, already_verified, already_paid, course_mode): """Determine which steps to display to the user. Includes all steps by default, but removes steps @@ -508,7 +523,7 @@ class PayAndVerifyView(View): display_steps = self.ALL_STEPS remove_steps = set() - if already_verified: + if already_verified or not CourseMode.is_verified_mode(course_mode): remove_steps |= set(self.VERIFICATION_STEPS) if already_paid and not always_show_payment: @@ -517,7 +532,6 @@ class PayAndVerifyView(View): # The "make payment" step doubles as an intro step, # so if we're showing the payment step, hide the intro step. remove_steps |= set([self.INTRO_STEP]) - return [ { 'name': step, @@ -642,15 +656,21 @@ def create_order(request): except decimal.InvalidOperation: return HttpResponseBadRequest(_("Selected price is not valid number.")) - # prefer professional mode over verified_mode - current_mode = CourseMode.verified_mode_for_course(course_id) + current_mode = None + paid_modes = CourseMode.paid_modes_for_course(course_id) + # Check if there are more than 1 paid(mode with min_price>0 e.g verified/professional/no-id-professional) modes + # for course exist then choose the first one + if paid_modes: + if len(paid_modes) > 1: + log.warn(u"Multiple paid course modes found for course '%s' for create order request", course_id) + current_mode = paid_modes[0] - # make sure this course has a verified mode + # Make sure this course has a paid mode if not current_mode: - log.warn(u"Verification requested for course {course_id} without a verified mode.".format(course_id=course_id)) - return HttpResponseBadRequest(_("This course doesn't support verified certificates")) + log.warn(u"Create order requested for course '%s' without a paid mode.", course_id) + return HttpResponseBadRequest(_("This course doesn't support paid certificates")) - if current_mode.slug == 'professional': + if CourseMode.is_professional_mode(current_mode): amount = current_mode.min_price if amount < current_mode.min_price: diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 697212378f..2a1bbdddad 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -6,6 +6,7 @@ from django.utils.translation import ungettext from django.core.urlresolvers import reverse from markupsafe import escape from courseware.courses import course_image_url, get_course_about_section +from course_modes.models import CourseMode from student.helpers import ( VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, @@ -30,18 +31,15 @@ from student.helpers import (
  • % if settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES'): - % if enrollment.mode == "verified": - % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED]: - <% mode_class = " verified" %> - % else: - <% mode_class = " honor" %> - % endif - % else: - <% mode_class = " " + enrollment.mode %> - % endif + <% course_verified_certs = CourseMode.enrollment_mode_display(enrollment.mode, verification_status.get('status')) %> + <% + mode_class = course_verified_certs.get('display_mode', '') + if mode_class != '': + mode_class = ' ' + mode_class ; + %> % else: - <% mode_class = "" %> - % endif + <% mode_class = '' %> + % endif
    @@ -64,44 +62,15 @@ from student.helpers import ( ${_('{course_number} {course_name} Cover Image').format(course_number=course.number, course_name=course.display_name_with_default) | h} % endif + % if settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES'): - % if enrollment.mode == "verified": - % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED]: - - ${_("Enrolled as: ")} - ## Translators: This text describes that the student has enrolled for a Verified Certificate, but verification of identity is pending. - ${_( - ## Translators: The student is enrolled for a Verified Certificate, but verification of identity is pending. -
    ${_("Verified: Pending Verification")}
    -
    - % elif verification_status.get('status') == VERIFY_STATUS_APPROVED: - - ${_("Enrolled as: ")} - ${_( -
    ${_("Verified")}
    -
    - % else: - - ${_("Enrolled as: ")} -
    ${_("Honor Code")}
    -
    - % endif - % elif enrollment.mode == "honor": - - ${_("Enrolled as: ")} -
    ${_("Honor Code")}
    -
    - % elif enrollment.mode == "audit": - - ${_("Enrolled as: ")} -
    ${_("Auditing")}
    -
    - % elif enrollment.mode == "professional": - + ${_("Enrolled as: ")} -
    ${_("Professional Ed")}
    + % if course_verified_certs.get('show_image'): + ${course_verified_certs.get('image_alt')} + % endif +
    ${course_verified_certs.get('enrollment_value')}
    - % endif % endif