Commit 2312b222 by Vedran Karačić

Merge pull request #11503 from edx/coupons/otto-checkout

Checkout on Otto
parents eb914a00 86a4710e
......@@ -3,28 +3,27 @@ Views for the course_mode module
"""
import decimal
from ipware.ip import get_ip
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect
from django.views.generic.base import View
from django.utils.translation import ugettext as _
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
from django.views.generic.base import View
from ipware.ip import get_ip
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore
from edxmako.shortcuts import render_to_response
from lms.djangoapps.commerce.utils import EcommerceService
from course_modes.models import CourseMode
from courseware.access import has_access
from edxmako.shortcuts import render_to_response
from embargo import api as embargo_api
from student.models import CourseEnrollment
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.keys import CourseKey
from util.db import outer_atomic
from xmodule.modulestore.django import modulestore
from embargo import api as embargo_api
class ChooseModeView(View):
......@@ -39,7 +38,14 @@ class ChooseModeView(View):
"""
@method_decorator(transaction.non_atomic_requests)
def dispatch(self, *args, **kwargs): # pylint: disable=missing-docstring
def dispatch(self, *args, **kwargs):
"""Disable atomicity for the view.
Otherwise, we'd be unable to commit to the database until the
request had concluded; Django will refuse to commit when an
atomic() block is active, since that would break atomicity.
"""
return super(ChooseModeView, self).dispatch(*args, **kwargs)
@method_decorator(login_required)
......@@ -117,7 +123,10 @@ class ChooseModeView(View):
)
context = {
"course_modes_choose_url": reverse("course_modes_choose", kwargs={'course_id': course_key.to_deprecated_string()}),
"course_modes_choose_url": reverse(
"course_modes_choose",
kwargs={'course_id': course_key.to_deprecated_string()}
),
"modes": modes,
"has_credit_upsell": has_credit_upsell,
"course_name": course.display_name_with_default_escaped,
......@@ -129,15 +138,22 @@ class ChooseModeView(View):
"nav_hidden": True,
}
if "verified" in modes:
verified_mode = modes["verified"]
context["suggested_prices"] = [
decimal.Decimal(x.strip())
for x in modes["verified"].suggested_prices.split(",")
for x in verified_mode.suggested_prices.split(",")
if x.strip()
]
context["currency"] = modes["verified"].currency.upper()
context["min_price"] = modes["verified"].min_price
context["verified_name"] = modes["verified"].name
context["verified_description"] = modes["verified"].description
context["currency"] = verified_mode.currency.upper()
context["min_price"] = verified_mode.min_price
context["verified_name"] = verified_mode.name
context["verified_description"] = verified_mode.description
if verified_mode.sku:
ecommerce_service = EcommerceService()
context["use_ecommerce_payment_flow"] = ecommerce_service.is_enabled()
context["ecommerce_payment_page"] = ecommerce_service.payment_page_url()
context["sku"] = verified_mode.sku
return render_to_response("course_modes/choose.html", context)
......
......@@ -207,7 +207,7 @@ def get_next_url_for_login_page(request):
"""
Determine the URL to redirect to following login/registration/third_party_auth
The user is currently on a login or reigration page.
The user is currently on a login or registration page.
If 'course_id' is set, or other POST_AUTH_PARAMS, we will need to send the user to the
/account/finish_auth/ view following login, which will take care of auto-enrollment in
the specified course.
......
......@@ -39,7 +39,6 @@ from django.template.response import TemplateResponse
from ratelimitbackend.exceptions import RateLimitException
from social.apps.django_app import utils as social_utils
from social.backends import oauth as social_oauth
from social.exceptions import AuthException, AuthAlreadyAssociated
......@@ -55,6 +54,7 @@ from student.models import (
create_comments_service_user, PasswordHistory, UserSignupSource,
DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED)
from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error
from certificates.models import CertificateStatuses, certificate_status_for_student
from certificates.api import ( # pylint: disable=import-error
......@@ -502,6 +502,7 @@ def complete_course_mode_info(course_id, enrollment, modes=None):
# if verified is an option.
if CourseMode.VERIFIED in modes and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES:
mode_info['show_upsell'] = True
mode_info['verified_sku'] = modes['verified'].sku
# if there is an expiration date, find out how long from now it is
if modes['verified'].expiration_datetime:
today = datetime.datetime.now(UTC).date()
......@@ -737,6 +738,13 @@ def dashboard(request):
'xseries_credentials': xseries_credentials,
}
ecommerce_service = EcommerceService()
if ecommerce_service.is_enabled():
context.update({
'use_ecommerce_payment_flow': True,
'ecommerce_payment_page': ecommerce_service.payment_page_url(),
})
return render_to_response('dashboard.html', context)
......
""" Admin site bindings for commerce app. """
from django.contrib import admin
from commerce.models import CommerceConfiguration
from config_models.admin import ConfigurationModelAdmin
admin.site.register(CommerceConfiguration, ConfigurationModelAdmin)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('commerce', '0001_data__add_ecommerce_service_user'),
]
operations = [
migrations.CreateModel(
name='CommerceConfiguration',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('checkout_on_ecommerce_service', models.BooleanField(default=False, help_text='Use the checkout page hosted by the E-Commerce service.')),
('single_course_checkout_page', models.CharField(default=b'/basket/single-item/', help_text='Path to single course checkout page hosted by the E-Commerce service.', max_length=255)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
]
"""
This file is intentionally empty. Django 1.6 and below require a models.py file for all apps.
Commerce-related models.
"""
from django.db import models
from django.utils.translation import ugettext_lazy as _
from config_models.models import ConfigurationModel
class CommerceConfiguration(ConfigurationModel):
""" Commerce configuration """
checkout_on_ecommerce_service = models.BooleanField(
default=False,
help_text=_('Use the checkout page hosted by the E-Commerce service.')
)
single_course_checkout_page = models.CharField(
max_length=255,
default='/basket/single-item/',
help_text=_('Path to single course checkout page hosted by the E-Commerce service.')
)
def __unicode__(self):
return "Commerce configuration"
"""Tests of commerce utilities."""
from django.test import TestCase
from django.test.utils import override_settings
from mock import patch
from commerce.utils import audit_log
from commerce.utils import audit_log, EcommerceService
from commerce.models import CommerceConfiguration
class AuditLogTests(TestCase):
......@@ -16,3 +18,46 @@ class AuditLogTests(TestCase):
# key-value pairs ordered alphabetically by key.
message = 'foo: bar="baz", qux="quux"'
self.assertTrue(mock_log.info.called_with(message))
class EcommerceServiceTests(TestCase):
"""Tests for the EcommerceService helper class."""
SKU = 'TESTSKU'
def setUp(self):
CommerceConfiguration.objects.create(
checkout_on_ecommerce_service=True,
single_course_checkout_page='/test_basket/'
)
super(EcommerceServiceTests, self).setUp()
def test_is_enabled(self):
"""Verify that is_enabled() returns True when ecomm checkout is enabled. """
is_enabled = EcommerceService().is_enabled()
self.assertTrue(is_enabled)
config = CommerceConfiguration.current()
config.checkout_on_ecommerce_service = False
config.save()
is_not_enabled = EcommerceService().is_enabled()
self.assertFalse(is_not_enabled)
@patch('openedx.core.djangoapps.theming.helpers.is_request_in_themed_site')
def test_is_enabled_for_microsites(self, is_microsite):
"""Verify that is_enabled() returns False if used for a microsite."""
is_microsite.return_value = True
is_not_enabled = EcommerceService().is_enabled()
self.assertFalse(is_not_enabled)
@override_settings(ECOMMERCE_PUBLIC_URL_ROOT='http://ecommerce_url')
def test_payment_page_url(self):
"""Verify that the proper URL is returned."""
url = EcommerceService().payment_page_url()
self.assertEqual(url, 'http://ecommerce_url/test_basket/')
@override_settings(ECOMMERCE_PUBLIC_URL_ROOT='http://ecommerce_url')
def test_checkout_page_url(self):
""" Verify the checkout page URL is properly constructed and returned. """
url = EcommerceService().checkout_page_url(self.SKU)
expected_url = 'http://ecommerce_url/test_basket/?sku={}'.format(self.SKU)
self.assertEqual(url, expected_url)
"""Utilities to assist with commerce tasks."""
import logging
from urlparse import urljoin
from django.conf import settings
from commerce.models import CommerceConfiguration
from openedx.core.djangoapps.theming import helpers
log = logging.getLogger(__name__)
......@@ -32,3 +37,29 @@ def audit_log(name, **kwargs):
message = u'{name}: {payload}'.format(name=name, payload=payload)
log.info(message)
class EcommerceService(object):
""" Helper class for ecommerce service integration. """
def __init__(self):
self.config = CommerceConfiguration.current()
def is_enabled(self):
""" Check if the service is enabled and that the site is not a microsite. """
return self.config.checkout_on_ecommerce_service and not helpers.is_request_in_themed_site()
def payment_page_url(self):
""" Return the URL for the checkout page.
Example:
http://localhost:8002/basket/single_item/
"""
return urljoin(settings.ECOMMERCE_PUBLIC_URL_ROOT, self.config.single_course_checkout_page)
def checkout_page_url(self, sku):
""" Construct the URL to the ecommerce checkout page and include a product.
Example:
http://localhost:8002/basket/single_item/?sku=5H3HG5
"""
return "{}?sku={}".format(self.payment_page_url(), sku)
......@@ -39,6 +39,7 @@ import survey.utils
import survey.views
from certificates import api as certs_api
from openedx.core.lib.gating import api as gating_api
from commerce.utils import EcommerceService
from course_modes.models import CourseMode
from courseware import grades
from courseware.access import has_access, has_ccx_coach_role, _adjust_start_date_for_beta_testers
......@@ -63,13 +64,13 @@ from courseware.url_helpers import get_redirect_url
from courseware.user_state_client import DjangoXBlockUserStateClient
from edxmako.shortcuts import render_to_response, render_to_string, marketing_link
from instructor.enrollment import uses_shib
from microsite_configuration import microsite
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credit.api import (
get_credit_requirement_status,
is_user_eligible_for_credit,
is_credit_course
)
from openedx.core.djangoapps.theming import helpers as theming_helpers
from shoppingcart.models import CourseRegistrationCode
from shoppingcart.utils import is_shopping_cart_enabled
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
......@@ -143,8 +144,10 @@ def courses(request):
if not settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'):
courses_list = get_courses(request.user)
if microsite.get_value("ENABLE_COURSE_SORTING_BY_START_DATE",
settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]):
if theming_helpers.get_value(
"ENABLE_COURSE_SORTING_BY_START_DATE",
settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]
):
courses_list = sort_by_start_date(courses_list)
else:
courses_list = sort_by_announcement(courses_list)
......@@ -508,7 +511,7 @@ def _index_bulk_op(request, course_key, chapter, section, position):
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
raise Http404
## Allow chromeless operation
# Allow chromeless operation
if section_descriptor.chrome:
chrome = [s.strip() for s in section_descriptor.chrome.lower().split(",")]
if 'accordion' not in chrome:
......@@ -855,8 +858,9 @@ def course_about(request, course_id):
with modulestore().bulk_operations(course_key):
permission = get_permission_for_course_about()
course = get_course_with_access(request.user, permission, course_key)
modes = CourseMode.modes_for_course_dict(course_key)
if microsite.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)):
if theming_helpers.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)):
return redirect(reverse('info', args=[course.id.to_deprecated_string()]))
registered = registered_for_course(course, request.user)
......@@ -871,10 +875,9 @@ def course_about(request, course_id):
show_courseware_link = bool(
(
has_access(request.user, 'load', course)
and has_access(request.user, 'view_courseware_with_prerequisites', course)
)
or settings.FEATURES.get('ENABLE_LMS_MIGRATION')
has_access(request.user, 'load', course) and
has_access(request.user, 'view_courseware_with_prerequisites', course)
) or settings.FEATURES.get('ENABLE_LMS_MIGRATION')
)
# Note: this is a flow for payment for course registration, not the Verified Certificate flow.
......@@ -884,15 +887,31 @@ def course_about(request, course_id):
_is_shopping_cart_enabled = is_shopping_cart_enabled()
if _is_shopping_cart_enabled:
registration_price = CourseMode.min_course_price_for_currency(course_key,
settings.PAID_COURSE_REGISTRATION_CURRENCY[0])
registration_price = CourseMode.min_course_price_for_currency(
course_key,
settings.PAID_COURSE_REGISTRATION_CURRENCY[0]
)
if request.user.is_authenticated():
cart = shoppingcart.models.Order.get_cart_for_user(request.user)
in_cart = shoppingcart.models.PaidCourseRegistration.contained_in_order(cart, course_key) or \
shoppingcart.models.CourseRegCodeItem.contained_in_order(cart, course_key)
reg_then_add_to_cart_link = "{reg_url}?course_id={course_id}&enrollment_action=add_to_cart".format(
reg_url=reverse('register_user'), course_id=urllib.quote(str(course_id)))
reg_url=reverse('register_user'), course_id=urllib.quote(str(course_id))
)
# If the ecommerce checkout flow is enabled and the mode of the course is
# professional or no id professional, we construct links for the enrollment
# button to add the course to the ecommerce basket.
ecommerce_checkout_link = ''
professional_mode = ''
ecomm_service = EcommerceService()
if ecomm_service.is_enabled() and (
CourseMode.PROFESSIONAL in modes or CourseMode.NO_ID_PROFESSIONAL_MODE in modes
):
professional_mode = modes.get(CourseMode.PROFESSIONAL, '') or \
modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE, '')
ecommerce_checkout_link = ecomm_service.checkout_page_url(professional_mode.sku)
course_price = get_cosmetic_display_price(course, registration_price)
can_add_course_to_cart = _is_shopping_cart_enabled and registration_price
......@@ -925,6 +944,9 @@ def course_about(request, course_id):
'is_cosmetic_price_enabled': settings.FEATURES.get('ENABLE_COSMETIC_DISPLAY_PRICE'),
'course_price': course_price,
'in_cart': in_cart,
'ecommerce_checkout': ecomm_service.is_enabled(),
'ecommerce_checkout_link': ecommerce_checkout_link,
'professional_mode': professional_mode,
'reg_then_add_to_cart_link': reg_then_add_to_cart_link,
'show_courseware_link': show_courseware_link,
'is_course_full': is_course_full,
......@@ -1577,12 +1599,12 @@ def financial_assistance_form(request):
enrolled_courses = [
{'name': enrollment.course_overview.display_name, 'value': unicode(enrollment.course_id)}
for enrollment in CourseEnrollment.enrollments_for_user(user).order_by('-created')
if CourseMode.objects.filter(
if enrollment.mode != CourseMode.VERIFIED and CourseMode.objects.filter(
Q(_expiration_datetime__isnull=True) | Q(_expiration_datetime__gt=datetime.now(UTC())),
course_id=enrollment.course_id,
mode_slug=CourseMode.VERIFIED
).exists()
and enrollment.mode != CourseMode.VERIFIED
]
return render_to_response('financial-assistance/apply.html', {
'header_text': FINANCIAL_ASSISTANCE_HEADER,
......
......@@ -51,7 +51,7 @@
enrollmentAction: $.url( '?enrollment_action' ),
courseId: $.url( '?course_id' ),
courseMode: $.url( '?course_mode' ),
emailOptIn: $.url( '?email_opt_in')
emailOptIn: $.url( '?email_opt_in' )
};
for (var key in queryParams) {
if (queryParams[key]) {
......
......@@ -21,7 +21,7 @@ from django.core.urlresolvers import reverse
} else {
title.attr("aria-expanded", "false");
}
}
};
$(document).ready(function() {
$('.expandable-area').slideUp();
......@@ -38,6 +38,12 @@ from django.core.urlresolvers import reverse
$('#contribution-other').attr('checked',true);
});
% if use_ecommerce_payment_flow:
$('input[name=verified_mode]').click(function(e){
e.preventDefault();
window.location.href = '${ecommerce_payment_page}?sku=${sku}';
});
% endif
});
</script>
</%block>
......
......@@ -39,6 +39,7 @@ from openedx.core.lib.courses import course_image_url
location.href = "${reg_then_add_to_cart_link}";
}
};
$("#add_to_cart_post").click(function(event){
$.ajax({
url: "${reverse('add_course_to_cart', args=[course.id.to_deprecated_string()])}",
......@@ -152,14 +153,27 @@ from openedx.core.lib.courses import course_image_url
reg_href = reg_then_add_to_cart_link
reg_element_id = "reg_then_add_to_cart"
%>
<% if ecommerce_checkout:
reg_href = ecommerce_checkout_link
reg_element_id = ""
%>
<a href="${reg_href}" class="add-to-cart" id="${reg_element_id}">
${_("Add {course_name} to Cart <span>({price} USD)</span>")\
.format(course_name=course.display_number_with_default, price=course_price)}
</a>
<div id="register_error"></div>
%else:
<a href="#" class="register">
<%
if ecommerce_checkout:
reg_href = ecommerce_checkout_link
else:
reg_href="#"
if professional_mode:
href_class = "add-to-cart"
else:
href_class = "register"
%>
<a href="${reg_href}" class="${href_class}">
${_("Enroll in {course_name}").format(course_name=course.display_number_with_default) | h}
</a>
<div id="register_error"></div>
......
......@@ -330,7 +330,11 @@ from student.helpers import (
${_("It's official. It's easily shareable. It's a proven motivator to complete the course. <br>{link_start}Learn more about the verified {cert_name_long}{link_end}.").format(link_start='<a href="{}" class="verified-info" data-course-key="{}">'.format(marketing_link('WHAT_IS_VERIFIED_CERT'), enrollment.course_id), link_end="</a>", cert_name_long=cert_name_long)}
</p>
<div class="action-upgrade-container">
<a class="action action-upgrade" href="${reverse('verify_student_upgrade_and_verify', kwargs={'course_id': unicode(course_overview.id)})}" data-course-id="${course_overview.id | h}" data-user="${user.username | h}">
% if use_ecommerce_payment_flow and course_mode_info['verified_sku']:
<a class="action action-upgrade" href="${ecommerce_payment_page}?sku=${course_mode_info['verified_sku']}">
% else:
<a class="action action-upgrade" href="${reverse('verify_student_upgrade_and_verify', kwargs={'course_id': unicode(course_overview.id)})}" data-course-id="${course_overview.id | h}" data-user="${user.username | h}">
% endif
<i class="action-upgrade-icon"></i>
<span class="wrapper-copy">
<span class="copy" id="upgrade-to-verified">${_("Upgrade to Verified")}</span>
......
......@@ -21,7 +21,7 @@ from django.core.urlresolvers import reverse
} else {
title.attr("aria-expanded", "false");
}
}
};
$(document).ready(function() {
$('.expandable-area').slideUp();
......@@ -38,6 +38,12 @@ from django.core.urlresolvers import reverse
$('#contribution-other').attr('checked',true);
});
% if use_ecommerce_payment_flow:
$('input[name=verified_mode]').click(function(e){
e.preventDefault();
window.location.href = '${ecommerce_payment_page}?sku=${sku}';
});
% endif
});
</script>
</%block>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment