diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index ccffd47568..2bd63ccf0d 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -112,6 +112,21 @@ class CourseMode(models.Model): else: return None + @classmethod + def verified_mode_for_course(cls, course_id): + """ + Since we have two separate modes that can go through the verify flow, + we want to be able to select the 'correct' verified mode for a given course. + + Currently, we prefer to return the professional mode over the verified one + if both exist for the given course. + """ + modes_dict = cls.modes_for_course_dict(course_id) + verified_mode = modes_dict.get('verified', None) + professional_mode = modes_dict.get('professional', None) + # 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): """ diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 933faf8c66..c369aaaf0f 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -113,3 +113,17 @@ class CourseModeModelTest(TestCase): modes = CourseMode.modes_for_course(SlashSeparatedCourseKey('TestOrg', 'TestCourse', 'TestRun')) self.assertEqual([CourseMode.DEFAULT_MODE], modes) + + def test_verified_mode_for_course(self): + self.create_mode('verified', 'Verified Certificate') + + mode = CourseMode.verified_mode_for_course(self.course_key) + + self.assertEqual(mode.slug, 'verified') + + # verify that the professional mode is preferred + self.create_mode('professional', 'Professional Education Verified Certificate') + + mode = CourseMode.verified_mode_for_course(self.course_key) + + self.assertEqual(mode.slug, 'professional') diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index a773c77f62..0aa242b4b2 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -59,8 +59,6 @@ class ChooseModeView(View): ) ) - - donation_for_course = request.session.get("donation_for_course", {}) chosen_price = donation_for_course.get(course_key, None) @@ -135,11 +133,6 @@ class ChooseModeView(View): donation_for_course = request.session.get("donation_for_course", {}) donation_for_course[course_key] = amount_value request.session["donation_for_course"] = donation_for_course - if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): - return redirect( - reverse('verify_student_verified', - kwargs={'course_id': course_key.to_deprecated_string()}) + "?upgrade={}".format(upgrade) - ) return redirect( reverse('verify_student_show_requirements', diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index d21d4e80eb..764a1b0896 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -968,7 +968,7 @@ class CourseEnrollment(models.Model): """ 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: + if paid_course or self.mode == 'professional': return True return False diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index eda01f7c17..47d5d327f1 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -701,8 +701,13 @@ class CertificateItem(OrderItem): item.qty = 1 item.unit_cost = cost course_name = modulestore().get_course(course_id).display_name - item.line_desc = _("Certificate of Achievement, {mode_name} for course {course}").format(mode_name=mode_info.name, - course=course_name) + # Translators: In this particular case, mode_name refers to a + # particular mode (i.e. Honor Code Certificate, Verified Certificate, etc) + # by which a user could enroll in the given course. + item.line_desc = _("{mode_name} for course {course}").format( + mode_name=mode_info.name, + course=course_name + ) item.currency = currency order.currency = currency order.save() @@ -725,7 +730,7 @@ class CertificateItem(OrderItem): @property def single_item_receipt_template(self): - if self.mode == 'verified': + if self.mode in ('verified', 'professional'): return 'shoppingcart/verified_cert_receipt.html' else: return super(CertificateItem, self).single_item_receipt_template diff --git a/lms/djangoapps/shoppingcart/tests/test_reports.py b/lms/djangoapps/shoppingcart/tests/test_reports.py index 22ae61fb19..e3f69dee9c 100644 --- a/lms/djangoapps/shoppingcart/tests/test_reports.py +++ b/lms/djangoapps/shoppingcart/tests/test_reports.py @@ -222,7 +222,7 @@ class ItemizedPurchaseReportTest(ModuleStoreTestCase): self.CORRECT_CSV = dedent(""" Purchase Time,Order ID,Status,Quantity,Unit Cost,Total Cost,Currency,Description,Comments {time_str},1,purchased,1,40,40,usd,Registration for Course: Robot Super Course,Ba\xc3\xbc\xe5\x8c\x85 - {time_str},1,purchased,1,40,40,usd,"Certificate of Achievement, verified cert for course Robot Super Course", + {time_str},1,purchased,1,40,40,usd,verified cert for course Robot Super Course, """.format(time_str=str(self.now))) def test_purchased_items_btw_dates(self): diff --git a/lms/djangoapps/verify_student/tests/test_integration.py b/lms/djangoapps/verify_student/tests/test_integration.py new file mode 100644 index 0000000000..1062eda8a1 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_integration.py @@ -0,0 +1,109 @@ +""" +Integration tests of the payment flow, including course mode selection. +""" + +from lxml.html import soupparser +from django.test.utils import override_settings +from django.core.urlresolvers import reverse + +from opaque_keys.edx.locations import SlashSeparatedCourseKey + +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from student.tests.factories import UserFactory +from course_modes.tests.factories import CourseModeFactory +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from verify_student.models import SoftwareSecurePhotoVerification + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestProfEdVerification(ModuleStoreTestCase): + """ + Integration test for professional ed verification, including course mode selection. + """ + + # Choose an uncommon number for the price so we can search for it on the page + MIN_PRICE = 1438 + + def setUp(self): + self.user = UserFactory.create(username="rusty", password="test") + self.client.login(username="rusty", password="test") + course = CourseFactory.create(org='Robot', number='999', display_name='Test Course') + self.course_key = course.id + CourseModeFactory( + mode_slug="professional", + course_id=self.course_key, + min_price=self.MIN_PRICE, + suggested_prices='' + ) + + self.urls = { + 'course_modes_choose': reverse( + 'course_modes_choose', + args=[unicode(self.course_key)] + ), + + 'verify_show_student_requirements': reverse( + 'verify_student_show_requirements', + args=[unicode(self.course_key)] + ), + + 'verify_student_verify': reverse( + 'verify_student_verify', + args=[unicode(self.course_key)] + ), + + 'verify_student_verified': reverse( + 'verify_student_verified', + args=[unicode(self.course_key)] + ) + "?upgrade=False", + } + + def test_new_user_flow(self): + # Go to the course mode page, expecting a redirect + # to the show requirements page + # because this is a professional ed course + # (otherwise, the student would have the option to choose their track) + resp = self.client.get(self.urls['course_modes_choose'], follow=True) + self.assertRedirects(resp, self.urls['verify_show_student_requirements']) + + # On the show requirements page, verify that there's a link to the verify page + # (this is the only action the user is allowed to take) + self.assertContains(resp, self.urls['verify_student_verify']) + + # Simulate the user clicking the button by following the link + # to the verified page. + # Since there are no suggested prices for professional ed, + # expect that only one price is displayed. + resp = self.client.get(self.urls['verify_student_verify']) + self.assertEqual(self._prices_on_page(resp.content), [self.MIN_PRICE]) + + def test_already_verified_user_flow(self): + # Simulate the user already being verified + self._verify_student() + + # Go to the course mode page, expecting a redirect to the + # verified (past tense!) page. + resp = self.client.get(self.urls['course_modes_choose'], follow=True) + self.assertRedirects(resp, self.urls['verify_student_verified']) + + # Since this is a professional ed course, expect that only + # one price is shown. + self.assertContains(resp, "Your Course Total is $") + self.assertContains(resp, str(self.MIN_PRICE)) + + # On the verified page, expect that there's a link to payment page + self.assertContains(resp, '/shoppingcart/payment_fake') + + def _prices_on_page(self, page_content): + """ Retrieve the available prices on the verify page. """ + html = soupparser.fromstring(page_content) + xpath_sel = '//li[@class="field contribution-option"]/span[@class="label-value"]/text()' + return [int(price) for price in html.xpath(xpath_sel)] + + def _verify_student(self): + """ Simulate that the student's identity has already been verified. """ + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + attempt.approve() diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index f87033441c..0400f7c269 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -74,18 +74,27 @@ class VerifyView(View): # bookkeeping-wise just to start over. progress_state = "start" - modes_dict = CourseMode.modes_for_course_dict(course_id) - verify_mode = modes_dict.get('verified', None) + # we prefer professional over verify + current_mode = CourseMode.verified_mode_for_course(course_id) + # if the course doesn't have a verified mode, we want to kick them # from the flow - if not verify_mode: + if not current_mode: return redirect(reverse('dashboard')) if course_id.to_deprecated_string() in request.session.get("donation_for_course", {}): chosen_price = request.session["donation_for_course"][course_id.to_deprecated_string()] else: - chosen_price = verify_mode.min_price + chosen_price = current_mode.min_price course = modulestore().get_course(course_id) + if current_mode.suggested_prices != '': + suggested_prices = [ + decimal.Decimal(price) + for price in current_mode.suggested_prices.split(",") + ] + else: + suggested_prices = [] + context = { "progress_state": progress_state, "user_full_name": request.user.profile.name, @@ -95,15 +104,13 @@ class VerifyView(View): "course_org": course.display_org_with_default, "course_num": course.display_number_with_default, "purchase_endpoint": get_purchase_endpoint(), - "suggested_prices": [ - decimal.Decimal(price) - for price in verify_mode.suggested_prices.split(",") - ], - "currency": verify_mode.currency.upper(), + "suggested_prices": suggested_prices, + "currency": current_mode.currency.upper(), "chosen_price": chosen_price, - "min_price": verify_mode.min_price, + "min_price": current_mode.min_price, "upgrade": upgrade == u'True', - "can_audit": "audit" in modes_dict, + "can_audit": CourseMode.mode_for_course(course_id, 'audit') is not None, + "modes_dict": CourseMode.modes_for_course_dict(course_id), } return render_to_response('verify_student/photo_verification.html', context) @@ -124,19 +131,20 @@ class VerifiedView(View): if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == ('verified', True): return redirect(reverse('dashboard')) + modes_dict = CourseMode.modes_for_course_dict(course_id) - verify_mode = modes_dict.get('verified', None) - if verify_mode is None: + # we prefer professional over verify + current_mode = CourseMode.verified_mode_for_course(course_id) + + # if the course doesn't have a verified mode, we want to kick them + # from the flow + if not current_mode: return redirect(reverse('dashboard')) - - chosen_price = request.session.get( - "donation_for_course", - {} - ).get( - course_id.to_deprecated_string(), - verify_mode.min_price - ) + if course_id.to_deprecated_string() in request.session.get("donation_for_course", {}): + chosen_price = request.session["donation_for_course"][course_id.to_deprecated_string()] + else: + chosen_price = current_mode.min_price course = modulestore().get_course(course_id) context = { @@ -146,11 +154,12 @@ class VerifiedView(View): "course_org": course.display_org_with_default, "course_num": course.display_number_with_default, "purchase_endpoint": get_purchase_endpoint(), - "currency": verify_mode.currency.upper(), + "currency": current_mode.currency.upper(), "chosen_price": chosen_price, "create_order_url": reverse("verify_student_create_order"), "upgrade": upgrade == u'True', "can_audit": "audit" in modes_dict, + "modes_dict": modes_dict, } return render_to_response('verify_student/verified.html', context) @@ -185,19 +194,24 @@ def create_order(request): donation_for_course[course_id] = amount request.session['donation_for_course'] = donation_for_course - verified_mode = CourseMode.modes_for_course_dict(course_id).get('verified', None) + # prefer professional mode over verified_mode + current_mode = CourseMode.verified_mode_for_course(course_id) + + if current_mode.slug == 'professional': + amount = current_mode.min_price # make sure this course has a verified mode - if not verified_mode: + if not current_mode: return HttpResponseBadRequest(_("This course doesn't support verified certificates")) - if amount < verified_mode.min_price: + if amount < current_mode.min_price: return HttpResponseBadRequest(_("No selected price or selected price is below minimum.")) # I know, we should check this is valid. All kinds of stuff missing here cart = Order.get_cart_for_user(request.user) cart.clear() - CertificateItem.add_to_order(cart, course_id, amount, 'verified') + enrollment_mode = current_mode.slug + CertificateItem.add_to_order(cart, course_id, amount, enrollment_mode) params = get_signed_purchase_params(cart) @@ -288,12 +302,20 @@ def show_requirements(request, course_id): """ Show the requirements necessary for the verification flow. """ + # TODO: seems borked for professional; we're told we need to take photos even if there's a pending verification course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + upgrade = request.GET.get('upgrade', False) if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == ('verified', True): return redirect(reverse('dashboard')) + if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): + return redirect( + reverse('verify_student_verified', + kwargs={'course_id': course_id.to_deprecated_string()}) + "?upgrade={}".format(upgrade) + ) upgrade = request.GET.get('upgrade', False) course = modulestore().get_course(course_id) + modes_dict = CourseMode.modes_for_course_dict(course_id) context = { "course_id": course_id.to_deprecated_string(), "course_modes_choose_url": reverse("course_modes_choose", kwargs={'course_id': course_id.to_deprecated_string()}), @@ -303,6 +325,7 @@ def show_requirements(request, course_id): "course_num": course.display_number_with_default, "is_not_active": not request.user.is_active, "upgrade": upgrade == u'True', + "modes_dict": modes_dict, } return render_to_response("verify_student/show_requirements.html", context) diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 21998b995e..2b6a316836 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -227,6 +227,13 @@ $verified-color-lvl3: $m-green-l2; $verified-color-lvl4: $m-green-l3; $verified-color-lvl5: $m-green-l4; +// STATE: professional ed +$professional-color-lvl1: $m-pink; +$professional-color-lvl2: $m-pink-l1; +$professional-color-lvl3: $m-pink-l2; +$professional-color-lvl4: $m-pink-l3; +$professional-color-lvl5: $m-pink-l4; + // STATE: honor code $honorcode-color-lvl1: rgb(50, 165, 217); $honorcode-color-lvl2: tint($honorcode-color-lvl1, 33%); diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index a883f244cd..6f5f4c1e71 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -495,6 +495,33 @@ // ==================== + // CASE: "enrolled as" status - professional ed + &.professional { + + // changes to cover + .cover { + border-color: $professional-color-lvl3; + padding: ($baseline/10); + } + + // course enrollment status message + .sts-enrollment { + position: absolute; + left: 30px; + width: auto; + + .label { + @extend %text-sr; + } + + // status message + .sts-enrollment-value { + background: $professional-color-lvl3; + color: tint($professional-color-lvl1, 95%); + } + } + } + // CASE: "enrolled as" status - verified &.verified { diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index d46634d520..a5aff31c18 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -56,6 +56,11 @@ ${_("Enrolled as: ")} ${_("Auditing")} + % elif enrollment.mode == "professional": + + ${_("Enrolled as: ")} + ${_("Professional Ed")} + % endif % endif diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 17170d04ef..f9338944a8 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -177,12 +177,13 @@
${_("To complete your registration, you will need to pay:")}
+