Fixed merge conflict between release and master.
This commit is contained in:
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
109
lms/djangoapps/verify_student/tests/test_integration.py
Normal file
109
lms/djangoapps/verify_student/tests/test_integration.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Auditing")}</span>
|
||||
</span>
|
||||
% elif enrollment.mode == "professional":
|
||||
<span class="sts-enrollment" title="${_("You're enrolled as a professional education student")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Professional Ed")}</span>
|
||||
</span>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
|
||||
@@ -177,12 +177,13 @@
|
||||
|
||||
<dt class="faq-question">${_("What do you do with this picture?")}</dt>
|
||||
<dd class="faq-answer">${_("We only use it to verify your identity. It is not displayed anywhere.")}</dd>
|
||||
<dt class="faq-question">${_("What if my camera isn't working?")}</dt>
|
||||
|
||||
%if upgrade:
|
||||
<dd class="faq-answer">${_("You can always continue to audit the course without verifying.")}</dd>
|
||||
%else:
|
||||
<dd class="faq-answer">${_("You can always {a_start} audit the course for free {a_end} without verifying.").format(a_start='<a rel="external" href="{}">'.format(course_modes_choose_url), a_end="</a>")}</dd>
|
||||
%if "professional" not in modes_dict:
|
||||
<dt class="faq-question">${_("What if my camera isn't working?")}</dt>
|
||||
%if upgrade:
|
||||
<dd class="faq-answer">${_("You can always continue to audit the course without verifying.")}</dd>
|
||||
%else:
|
||||
<dd class="faq-answer">${_("You can always {a_start} audit the course for free {a_end} without verifying.").format(a_start='<a rel="external" href="{}">'.format(course_modes_choose_url), a_end="</a>")}</dd>
|
||||
%endif
|
||||
%endif
|
||||
</dl>
|
||||
</div>
|
||||
@@ -366,6 +367,7 @@
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
%if len(suggested_prices) > 0:
|
||||
<li class="review-task review-task-contribution">
|
||||
<h4 class="title">${_("Check Your Contribution Level")}</h4>
|
||||
|
||||
@@ -376,12 +378,28 @@
|
||||
<%include file="/course_modes/_contribution.html" args="suggested_prices=suggested_prices, currency=currency, chosen_price=chosen_price, min_price=min_price"/>
|
||||
|
||||
</li>
|
||||
%else:
|
||||
<li class="review-task review-task-contribution">
|
||||
<h4 class="title">${_("Your Course Total")}</h4>
|
||||
<div class="copy">
|
||||
<p>${_("To complete your registration, you will need to pay:")}</p>
|
||||
</div>
|
||||
<ul class="list-fields contribution-options">
|
||||
<li class="field contribution-option">
|
||||
<span class="deco-denomination">$</span>
|
||||
<span class="label-value">${chosen_price}</span>
|
||||
<span class="denomination-name">${currency}</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
%endif
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<nav class="nav-wizard">
|
||||
<div class="prompt-verify">
|
||||
<h3 class="title">Before proceeding, please confirm that your details match</h3>
|
||||
<h3 class="title">${_("Before proceeding, please confirm that your details match")}</h3>
|
||||
|
||||
<p class="copy"> ${_("Once you verify your details match the requirements, you can move on to step 4, payment on our secure server.")}</p>
|
||||
|
||||
|
||||
@@ -85,8 +85,11 @@ $(document).ready(function() {
|
||||
</div>
|
||||
|
||||
<nav class="nav-wizard is-ready">
|
||||
|
||||
%if "professional" in modes_dict:
|
||||
<span class="help help-inline price-value">${_("Your Course Total is $ ")} <strong>${chosen_price}</strong></span>
|
||||
%else:
|
||||
<span class="help help-inline price-value">${_("You have decided to pay $ ")} <strong>${chosen_price}</strong></span>
|
||||
%endif
|
||||
|
||||
<ol class="wizard-steps">
|
||||
<li class="wizard-step step-proceed">
|
||||
|
||||
Reference in New Issue
Block a user