Commit c4eee8eb by Will Daly

Merge pull request #5502 from edx/will/per-course-donation-button

Add donation button to the enrollment success message
parents 2ebed452 f8365a2d
"""Decorators for model-based configuration. """
from functools import wraps
from django.http import HttpResponseNotFound
def require_config(config_model):
"""View decorator that enables/disables a view based on configuration.
Arguments:
config_model (ConfigurationModel subclass): The class of the configuration
model to check.
Returns:
HttpResponse: 404 if the configuration model is disabled,
otherwise returns the response from the decorated view.
"""
def _decorator(func):
@wraps(func)
def _inner(*args, **kwargs):
if not config_model.current().enabled:
return HttpResponseNotFound()
else:
return func(*args, **kwargs)
return _inner
return _decorator
......@@ -59,6 +59,9 @@ class CourseMode(models.Model):
DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd', None, None)
DEFAULT_MODE_SLUG = 'honor'
# Modes that allow a student to pursue a verified certificate
VERIFIED_MODES = ["verified", "professional"]
class Meta:
""" meta attributes of this model """
unique_together = ('course_id', 'mode_slug', 'currency')
......@@ -128,6 +131,22 @@ class CourseMode(models.Model):
return professional_mode if professional_mode else verified_mode
@classmethod
def has_verified_mode(cls, course_mode_dict):
"""Check whether the modes for a course allow a student to pursue a verfied certificate.
Args:
course_mode_dict (dictionary mapping course mode slugs to Modes)
Returns:
bool: True iff the course modes contain a verified track.
"""
for mode in cls.VERIFIED_MODES:
if mode in course_mode_dict:
return True
return False
@classmethod
def min_course_price_for_verified_for_currency(cls, course_id, currency):
"""
Returns the minimum price of the course int he appropriate currency over all the
......
......@@ -8,24 +8,32 @@ from django.test import Client
from opaque_keys.edx import locator
from pytz import UTC
import unittest
import ddt
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.tests.factories import CourseModeFactory
from student.models import CourseEnrollment, DashboardConfiguration
from student.views import get_course_enrollment_pairs, _get_recently_enrolled_courses
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt
class TestRecentEnrollments(ModuleStoreTestCase):
"""
Unit tests for getting the list of courses for a logged in user
"""
PASSWORD = 'test'
def setUp(self):
"""
Add a student
"""
super(TestRecentEnrollments, self).setUp()
self.student = UserFactory()
self.student.set_password(self.PASSWORD)
self.student.save()
# Old Course
old_course_location = locator.CourseLocator('Org0', 'Course0', 'Run0')
......@@ -35,7 +43,7 @@ class TestRecentEnrollments(ModuleStoreTestCase):
# New Course
course_location = locator.CourseLocator('Org1', 'Course1', 'Run1')
self._create_course_and_enrollment(course_location)
self.course, _ = self._create_course_and_enrollment(course_location)
def _create_course_and_enrollment(self, course_location):
""" Creates a course and associated enrollment. """
......@@ -47,12 +55,17 @@ class TestRecentEnrollments(ModuleStoreTestCase):
enrollment = CourseEnrollment.enroll(self.student, course.id)
return course, enrollment
def _configure_message_timeout(self, timeout):
"""Configure the amount of time the enrollment message will be displayed. """
config = DashboardConfiguration(recent_enrollment_time_delta=timeout)
config.save()
def test_recently_enrolled_courses(self):
"""
Test if the function for filtering recent enrollments works appropriately.
"""
config = DashboardConfiguration(recent_enrollment_time_delta=60)
config.save()
self._configure_message_timeout(60)
# get courses through iterating all courses
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
self.assertEqual(len(courses_list), 2)
......@@ -64,8 +77,7 @@ class TestRecentEnrollments(ModuleStoreTestCase):
"""
Tests that the recent enrollment list is empty if configured to zero seconds.
"""
config = DashboardConfiguration(recent_enrollment_time_delta=0)
config.save()
self._configure_message_timeout(0)
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
self.assertEqual(len(courses_list), 2)
......@@ -78,30 +90,21 @@ class TestRecentEnrollments(ModuleStoreTestCase):
recent enrollments first.
"""
config = DashboardConfiguration(recent_enrollment_time_delta=600)
config.save()
self._configure_message_timeout(600)
# Create a number of new enrollments and courses, and force their creation behind
# the first enrollment
course_location = locator.CourseLocator('Org2', 'Course2', 'Run2')
_, enrollment2 = self._create_course_and_enrollment(course_location)
enrollment2.created = datetime.datetime.now(UTC) - datetime.timedelta(seconds=5)
enrollment2.save()
course_location = locator.CourseLocator('Org3', 'Course3', 'Run3')
_, enrollment3 = self._create_course_and_enrollment(course_location)
enrollment3.created = datetime.datetime.now(UTC) - datetime.timedelta(seconds=10)
enrollment3.save()
course_location = locator.CourseLocator('Org4', 'Course4', 'Run4')
_, enrollment4 = self._create_course_and_enrollment(course_location)
enrollment4.created = datetime.datetime.now(UTC) - datetime.timedelta(seconds=15)
enrollment4.save()
course_location = locator.CourseLocator('Org5', 'Course5', 'Run5')
_, enrollment5 = self._create_course_and_enrollment(course_location)
enrollment5.created = datetime.datetime.now(UTC) - datetime.timedelta(seconds=20)
enrollment5.save()
courses = []
for idx, seconds_past in zip(range(2, 6), [5, 10, 15, 20]):
course_location = locator.CourseLocator(
'Org{num}'.format(num=idx),
'Course{num}'.format(num=idx),
'Run{num}'.format(num=idx)
)
course, enrollment = self._create_course_and_enrollment(course_location)
enrollment.created = datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds_past)
enrollment.save()
courses.append(course)
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
self.assertEqual(len(courses_list), 6)
......@@ -109,19 +112,42 @@ class TestRecentEnrollments(ModuleStoreTestCase):
recent_course_list = _get_recently_enrolled_courses(courses_list)
self.assertEqual(len(recent_course_list), 5)
self.assertEqual(recent_course_list[1][1], enrollment2)
self.assertEqual(recent_course_list[2][1], enrollment3)
self.assertEqual(recent_course_list[3][1], enrollment4)
self.assertEqual(recent_course_list[4][1], enrollment5)
self.assertEqual(recent_course_list[1], courses[0])
self.assertEqual(recent_course_list[2], courses[1])
self.assertEqual(recent_course_list[3], courses[2])
self.assertEqual(recent_course_list[4], courses[3])
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_dashboard_rendering(self):
"""
Tests that the dashboard renders the recent enrollment messages appropriately.
"""
config = DashboardConfiguration(recent_enrollment_time_delta=600)
config.save()
self.client = Client()
self.client.login(username=self.student.username, password='test')
self._configure_message_timeout(600)
self.client.login(username=self.student.username, password=self.PASSWORD)
response = self.client.get(reverse("dashboard"))
self.assertContains(response, "You have successfully enrolled in")
@ddt.data(
(['audit', 'honor', 'verified'], False),
(['professional'], False),
(['verified'], False),
(['audit'], True),
(['honor'], True),
([], True)
)
@ddt.unpack
def test_donate_button(self, course_modes, show_donate):
# Enable the enrollment success message
self._configure_message_timeout(10000)
# Create the course mode(s)
for mode in course_modes:
CourseModeFactory(mode_slug=mode, course_id=self.course.id)
# Check that the donate button is or is not displayed
self.client.login(username=self.student.username, password=self.PASSWORD)
response = self.client.get(reverse("dashboard"))
if show_donate:
self.assertContains(response, "donate-container")
else:
self.assertNotContains(response, "donate-container")
......@@ -415,7 +415,7 @@ def register_user(request, extra_context=None):
return render_to_response('register.html', context)
def complete_course_mode_info(course_id, enrollment):
def complete_course_mode_info(course_id, enrollment, modes=None):
"""
We would like to compute some more information from the given course modes
and the user's current enrollment
......@@ -424,7 +424,9 @@ def complete_course_mode_info(course_id, enrollment):
- whether to show the course upsell information
- numbers of days until they can't upsell anymore
"""
modes = CourseMode.modes_for_course_dict(course_id)
if modes is None:
modes = CourseMode.modes_for_course_dict(course_id)
mode_info = {'show_upsell': False, 'days_for_upsell': None}
# we want to know if the user is already verified and if verified is an
# option
......@@ -475,9 +477,17 @@ def dashboard(request):
# enrollments, because it could have been a data push snafu.
course_enrollment_pairs = list(get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set))
# Check to see if the student has recently enrolled in a course. If so, display a notification message confirming
# the enrollment.
enrollment_message = _create_recent_enrollment_message(course_enrollment_pairs)
# Retrieve the course modes for each course
course_modes_by_course = {
course.id: CourseMode.modes_for_course_dict(course.id)
for course, __ in course_enrollment_pairs
}
# Check to see if the student has recently enrolled in a course.
# If so, display a notification message confirming the enrollment.
enrollment_message = _create_recent_enrollment_message(
course_enrollment_pairs, course_modes_by_course
)
course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
......@@ -499,8 +509,21 @@ def dashboard(request):
show_courseware_links_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if has_access(request.user, 'load', course))
course_modes = {course.id: complete_course_mode_info(course.id, enrollment) for course, enrollment in course_enrollment_pairs}
cert_statuses = {course.id: cert_info(request.user, course) for course, _enrollment in course_enrollment_pairs}
# Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict
# we loaded earlier to avoid hitting the database.
course_mode_info = {
course.id: complete_course_mode_info(
course.id, enrollment,
modes=course_modes_by_course[course.id]
)
for course, enrollment in course_enrollment_pairs
}
cert_statuses = {
course.id: cert_info(request.user, course)
for course, _enrollment in course_enrollment_pairs
}
# only show email settings for Mongo course and when bulk email is turned on
show_email_settings_for = frozenset(
......@@ -570,7 +593,7 @@ def dashboard(request):
'staff_access': staff_access,
'errored_courses': errored_courses,
'show_courseware_links_for': show_courseware_links_for,
'all_course_modes': course_modes,
'all_course_modes': course_mode_info,
'cert_statuses': cert_statuses,
'show_email_settings_for': show_email_settings_for,
'reverifications': reverifications,
......@@ -598,23 +621,35 @@ def dashboard(request):
return render_to_response('dashboard.html', context)
def _create_recent_enrollment_message(course_enrollment_pairs):
def _create_recent_enrollment_message(course_enrollment_pairs, course_modes):
"""Builds a recent course enrollment message
Constructs a new message template based on any recent course enrollments for the student.
Args:
course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information.
course_modes (dict): Mapping of course ID's to course mode dictionaries.
Returns:
A string representing the HTML message output from the message template.
None if there are no recently enrolled courses.
"""
recent_course_enrollment_pairs = _get_recently_enrolled_courses(course_enrollment_pairs)
if recent_course_enrollment_pairs:
recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollment_pairs)
if recently_enrolled_courses:
messages = [
{
"course_id": course.id,
"course_name": course.display_name,
"allow_donation": not CourseMode.has_verified_mode(course_modes[course.id])
}
for course in recently_enrolled_courses
]
return render_to_string(
'enrollment/course_enrollment_message.html',
{'recent_course_enrollment_pairs': recent_course_enrollment_pairs,}
{'course_enrollment_messages': messages}
)
......@@ -627,14 +662,14 @@ def _get_recently_enrolled_courses(course_enrollment_pairs):
course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information.
Returns:
A list of tuples for the course and enrollment.
A list of courses
"""
seconds = DashboardConfiguration.current().recent_enrollment_time_delta
sorted_list = sorted(course_enrollment_pairs, key=lambda created: created[1].created, reverse=True)
time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds))
return [
(course, enrollment) for course, enrollment in sorted_list
course for course, enrollment in sorted_list
# If the enrollment has no created date, we are explicitly excluding the course
# from the list of recent enrollments.
if enrollment.is_active and enrollment.created > time_delta
......
......@@ -2,7 +2,9 @@
Allows django admin site to add PaidCourseRegistrationAnnotations
"""
from ratelimitbackend import admin
from shoppingcart.models import PaidCourseRegistrationAnnotation, Coupon
from shoppingcart.models import (
PaidCourseRegistrationAnnotation, Coupon, DonationConfiguration
)
class SoftDeleteCouponAdmin(admin.ModelAdmin):
......@@ -49,3 +51,4 @@ class SoftDeleteCouponAdmin(admin.ModelAdmin):
admin.site.register(PaidCourseRegistrationAnnotation)
admin.site.register(Coupon, SoftDeleteCouponAdmin)
admin.site.register(DonationConfiguration)
......@@ -22,6 +22,7 @@ from model_utils.managers import InheritanceManager
from xmodule.modulestore.django import modulestore
from config_models.models import ConfigurationModel
from course_modes.models import CourseMode
from edxmako.shortcuts import render_to_string
from student.models import CourseEnrollment, UNENROLL_DONE
......@@ -870,6 +871,11 @@ class CertificateItem(OrderItem):
unit_cost__gt=(CourseMode.min_course_price_for_verified_for_currency(course_id, 'usd')))).count()
class DonationConfiguration(ConfigurationModel):
"""Configure whether donations are enabled on the site."""
pass
class Donation(OrderItem):
"""A donation made by a user.
......@@ -984,7 +990,7 @@ class Donation(OrderItem):
course_id (CourseKey)
Raises:
InvalidCartItem: The course ID is not valid.
CourseDoesNotExistException: The course ID is not valid.
Returns:
unicode
......@@ -998,7 +1004,7 @@ class Donation(OrderItem):
err = _(
u"Could not find a course with the ID '{course_id}'"
).format(course_id=course_id)
raise InvalidCartItem(err)
raise CourseDoesNotExistException(err)
return _(u"Donation for {course}").format(course=course.display_name)
......
......@@ -196,7 +196,7 @@ def sign(params):
return params
def render_purchase_form_html(cart, callback_url=None):
def render_purchase_form_html(cart, callback_url=None, extra_data=None):
"""
Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource
......@@ -209,17 +209,21 @@ def render_purchase_form_html(cart, callback_url=None):
the URL provided by the administrator of the account
(CyberSource config, not LMS config).
extra_data (list): Additional data to include as merchant-defined data fields.
Returns:
unicode: The rendered HTML form.
"""
return render_to_string('shoppingcart/cybersource_form.html', {
'action': get_purchase_endpoint(),
'params': get_signed_purchase_params(cart, callback_url=callback_url),
'params': get_signed_purchase_params(
cart, callback_url=callback_url, extra_data=extra_data
),
})
def get_signed_purchase_params(cart, callback_url=None):
def get_signed_purchase_params(cart, callback_url=None, extra_data=None):
"""
This method will return a digitally signed set of CyberSource parameters
......@@ -232,14 +236,16 @@ def get_signed_purchase_params(cart, callback_url=None):
the URL provided by the administrator of the account
(CyberSource config, not LMS config).
extra_data (list): Additional data to include as merchant-defined data fields.
Returns:
dict
"""
return sign(get_purchase_params(cart, callback_url=callback_url))
return sign(get_purchase_params(cart, callback_url=callback_url, extra_data=extra_data))
def get_purchase_params(cart, callback_url=None):
def get_purchase_params(cart, callback_url=None, extra_data=None):
"""
This method will build out a dictionary of parameters needed by CyberSource to complete the transaction
......@@ -252,6 +258,8 @@ def get_purchase_params(cart, callback_url=None):
the URL provided by the administrator of the account
(CyberSource config, not LMS config).
extra_data (list): Additional data to include as merchant-defined data fields.
Returns:
dict
......@@ -280,6 +288,12 @@ def get_purchase_params(cart, callback_url=None):
params['override_custom_receipt_page'] = callback_url
params['override_custom_cancel_page'] = callback_url
if extra_data is not None:
# CyberSource allows us to send additional data in "merchant defined data" fields
for num, item in enumerate(extra_data, start=1):
key = u"merchant_defined_data{num}".format(num=num)
params[key] = item
return params
......
......@@ -27,9 +27,9 @@ from shoppingcart.models import (
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from shoppingcart.exceptions import PurchasedCallbackException
from shoppingcart.exceptions import PurchasedCallbackException, CourseDoesNotExistException
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
......@@ -321,7 +321,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
self.assertEqual(reg1.status, "cart")
self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_key))
self.assertFalse(PaidCourseRegistration.contained_in_order(
self.cart, SlashSeparatedCourseKey("MITx", "999", "Robot_Super_Course_abcd"))
self.cart, CourseLocator(org="MITx", course="999", run="Robot_Super_Course_abcd"))
)
self.assertEqual(self.cart.total_cost, self.cost)
......@@ -370,13 +370,13 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
def test_purchased_callback_exception(self):
reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_key)
reg1.course_id = SlashSeparatedCourseKey("changed", "forsome", "reason")
reg1.course_id = CourseLocator(org="changed", course="forsome", run="reason")
reg1.save()
with self.assertRaises(PurchasedCallbackException):
reg1.purchased_callback()
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_key))
reg1.course_id = SlashSeparatedCourseKey("abc", "efg", "hij")
reg1.course_id = CourseLocator(org="abc", course="efg", run="hij")
reg1.save()
with self.assertRaises(PurchasedCallbackException):
reg1.purchased_callback()
......@@ -595,11 +595,6 @@ class DonationTest(ModuleStoreTestCase):
line_desc=u"Donation for Test Course"
)
def test_donate_no_such_course(self):
fake_course_id = SlashSeparatedCourseKey("edx", "fake", "course")
with self.assertRaises(InvalidCartItem):
Donation.add_to_order(self.cart, self.COST, course_id=fake_course_id)
def test_confirmation_email(self):
# Pay for a donation
Donation.add_to_order(self.cart, self.COST)
......@@ -612,6 +607,11 @@ class DonationTest(ModuleStoreTestCase):
self.assertEquals('Order Payment Confirmation', email.subject)
self.assertIn("tax deductible", email.body)
def test_donate_no_such_course(self):
fake_course_id = CourseLocator(org="edx", course="fake", run="course")
with self.assertRaises(CourseDoesNotExistException):
Donation.add_to_order(self.cart, self.COST, course_id=fake_course_id)
def _assert_donation(self, donation, donation_type=None, course_id=None, unit_cost=None, line_desc=None):
"""Verify the donation fields and that the donation can be purchased. """
self.assertEqual(donation.order, self.cart)
......
"""
Tests for Shopping Cart views
"""
from django.http import HttpRequest
import json
from urlparse import urlparse
from decimal import Decimal
from django.http import HttpRequest
from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
......@@ -17,6 +19,8 @@ from django.core.cache import cache
from pytz import UTC
from freezegun import freeze_time
from datetime import datetime, timedelta
from mock import patch, Mock
import ddt
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
......@@ -26,7 +30,7 @@ from shoppingcart.views import _can_download_report, _get_date_from_str
from shoppingcart.models import (
Order, CertificateItem, PaidCourseRegistration,
Coupon, CourseRegistrationCode, RegistrationCodeRedemption,
Donation
DonationConfiguration
)
from student.tests.factories import UserFactory, AdminFactory
from courseware.tests.factories import InstructorFactory
......@@ -35,9 +39,8 @@ from course_modes.models import CourseMode
from edxmako.shortcuts import render_to_response
from shoppingcart.processors import render_purchase_form_html
from shoppingcart.admin import SoftDeleteCouponAdmin
from mock import patch, Mock
from shoppingcart.views import initialize_report
from decimal import Decimal
from shoppingcart.tests.payment_fake import PaymentFakeView
def mock_render_purchase_form_html(*args, **kwargs):
......@@ -868,15 +871,20 @@ class RegistrationCodeRedemptionCourseEnrollment(ModuleStoreTestCase):
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class DonationReceiptViewTest(ModuleStoreTestCase):
"""Tests for the receipt page when the user pays for a donation. """
@ddt.ddt
class DonationViewTest(ModuleStoreTestCase):
"""Tests for making a donation.
These tests cover both the single-item purchase flow,
as well as the receipt page for donation items.
"""
COST = Decimal('23.45')
DONATION_AMOUNT = "23.45"
PASSWORD = "password"
def setUp(self):
"""Create a test user and order. """
super(DonationReceiptViewTest, self).setUp()
super(DonationViewTest, self).setUp()
# Create and login a user
self.user = UserFactory.create()
......@@ -885,37 +893,131 @@ class DonationReceiptViewTest(ModuleStoreTestCase):
result = self.client.login(username=self.user.username, password=self.PASSWORD)
self.assertTrue(result)
# Create an order for the user
self.cart = Order.get_cart_for_user(self.user)
# Enable donations
config = DonationConfiguration.current()
config.enabled = True
config.save()
def test_donation_for_org_receipt(self):
# Purchase the donation
Donation.add_to_order(self.cart, self.COST)
self.cart.start_purchase()
self.cart.purchase()
# Verify the receipt page
def test_donation_for_org(self):
self._donate(self.DONATION_AMOUNT)
self._assert_receipt_contains("tax deductible")
def test_donation_for_course_receipt(self):
# Create a test course
# Create a test course and donate to it
self.course = CourseFactory.create(display_name="Test Course")
# Purchase the donation for the course
Donation.add_to_order(self.cart, self.COST, course_id=self.course.id)
self.cart.start_purchase()
self.cart.purchase()
self._donate(self.DONATION_AMOUNT, course_id=self.course.id)
# Verify the receipt page
self._assert_receipt_contains("tax deductible")
self._assert_receipt_contains(self.course.display_name)
def test_smallest_possible_donation(self):
self._donate("0.01")
self._assert_receipt_contains("0.01")
@ddt.data(
{},
{"amount": "abcd"},
{"amount": "-1.00"},
{"amount": "0.00"},
{"amount": "0.001"},
{"amount": "0"},
{"amount": "23.45", "course_id": "invalid"}
)
def test_donation_bad_request(self, bad_params):
response = self.client.post(reverse('donation'), bad_params)
self.assertEqual(response.status_code, 400)
def test_donation_requires_login(self):
self.client.logout()
response = self.client.post(reverse('donation'), {'amount': self.DONATION_AMOUNT})
self.assertEqual(response.status_code, 302)
def test_no_such_course(self):
response = self.client.post(
reverse("donation"),
{"amount": self.DONATION_AMOUNT, "course_id": "edx/DemoX/Demo"}
)
self.assertEqual(response.status_code, 400)
@ddt.data("get", "put", "head", "options", "delete")
def test_donation_requires_post(self, invalid_method):
response = getattr(self.client, invalid_method)(
reverse("donation"), {"amount": self.DONATION_AMOUNT}
)
self.assertEqual(response.status_code, 405)
def test_donations_disabled(self):
config = DonationConfiguration.current()
config.enabled = False
config.save()
# Logged in -- should be a 404
response = self.client.post(reverse('donation'))
self.assertEqual(response.status_code, 404)
# Logged out -- should still be a 404
self.client.logout()
response = self.client.post(reverse('donation'))
self.assertEqual(response.status_code, 404)
def _donate(self, donation_amount, course_id=None):
"""Simulate a donation to a course.
This covers the entire payment flow, except for the external
payment processor, which is simulated.
Arguments:
donation_amount (unicode): The amount the user is donating.
Keyword Arguments:
course_id (CourseKey): If provided, make a donation to the specific course.
Raises:
AssertionError
"""
# Purchase a single donation item
# Optionally specify a particular course for the donation
params = {'amount': donation_amount}
if course_id is not None:
params['course_id'] = course_id
url = reverse('donation')
response = self.client.post(url, params)
self.assertEqual(response.status_code, 200)
# Use the fake payment implementation to simulate the parameters
# we would receive from the payment processor.
payment_info = json.loads(response.content)
self.assertEqual(payment_info["payment_url"], "/shoppingcart/payment_fake")
# If this is a per-course donation, verify that we're sending
# the course ID to the payment processor.
if course_id is not None:
self.assertEqual(
payment_info["payment_params"]["merchant_defined_data1"],
unicode(course_id)
)
processor_response_params = PaymentFakeView.response_post_params(payment_info["payment_params"])
# Use the response parameters to simulate a successful payment
url = reverse('shoppingcart.views.postpay_callback')
response = self.client.post(url, processor_response_params)
self.assertRedirects(response, self._receipt_url)
def _assert_receipt_contains(self, expected_text):
"""Load the receipt page and verify that it contains the expected text."""
url = reverse("shoppingcart.views.show_receipt", kwargs={"ordernum": self.cart.id})
resp = self.client.get(url)
resp = self.client.get(self._receipt_url)
self.assertContains(resp, expected_text)
@property
def _receipt_url(self):
order_id = Order.objects.get(user=self.user, status="purchased").id
return reverse("shoppingcart.views.show_receipt", kwargs={"ordernum": order_id})
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class CSVReportViewsTest(ModuleStoreTestCase):
......
......@@ -4,6 +4,7 @@ from django.conf import settings
urlpatterns = patterns('shoppingcart.views', # nopep8
url(r'^postpay_callback/$', 'postpay_callback'), # Both the ~accept and ~reject callback pages are handled here
url(r'^receipt/(?P<ordernum>[0-9]*)/$', 'show_receipt'),
url(r'^donation/$', 'donate', name='donation'),
url(r'^csv_report/$', 'csv_report', name='payment_csv_report'),
)
......
import logging
import datetime
import decimal
import pytz
from django.conf import settings
from django.contrib.auth.models import Group
from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseNotFound,
HttpResponseBadRequest, HttpResponseForbidden, Http404)
from django.http import (
HttpResponse, HttpResponseRedirect, HttpResponseNotFound,
HttpResponseBadRequest, HttpResponseForbidden, Http404
)
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_POST, require_http_methods
from django.core.urlresolvers import reverse
......@@ -14,15 +17,28 @@ from util.bad_request_rate_limiter import BadRequestRateLimiter
from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_response
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator
from opaque_keys import InvalidKeyError
from courseware.courses import get_course_by_id
from courseware.views import registered_for_course
from config_models.decorators import require_config
from shoppingcart.reports import RefundReport, ItemizedPurchaseReport, UniversityRevenueShareReport, CertificateStatusReport
from student.models import CourseEnrollment
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException, \
RegCodeAlreadyExistException, ItemDoesNotExistAgainstRegCodeException,\
MultipleCouponsNotAllowedException
from .models import Order, PaidCourseRegistration, OrderItem, Coupon, CouponRedemption, CourseRegistrationCode, RegistrationCodeRedemption
from .processors import process_postpay_callback, render_purchase_form_html
from .exceptions import (
ItemAlreadyInCartException, AlreadyEnrolledInCourseException,
CourseDoesNotExistException, ReportTypeDoesNotExistException,
RegCodeAlreadyExistException, ItemDoesNotExistAgainstRegCodeException,
MultipleCouponsNotAllowedException, InvalidCartItem
)
from .models import (
Order, PaidCourseRegistration, OrderItem, Coupon,
CouponRedemption, CourseRegistrationCode, RegistrationCodeRedemption,
Donation, DonationConfiguration
)
from .processors import (
process_postpay_callback, render_purchase_form_html,
get_signed_purchase_params, get_purchase_endpoint
)
import json
from xmodule_django.models import CourseKeyField
......@@ -48,6 +64,7 @@ def initialize_report(report_type, start_date, end_date, start_letter=None, end_
return item[1](start_date, end_date, start_letter, end_letter)
raise ReportTypeDoesNotExistException
@require_POST
def add_course_to_cart(request, course_id):
"""
......@@ -308,6 +325,109 @@ def register_courses(request):
return HttpResponse(json.dumps({'response': 'success'}), content_type="application/json")
@require_config(DonationConfiguration)
@require_POST
@login_required
def donate(request):
"""Add a single donation item to the cart and proceed to payment.
Warning: this call will clear all the items in the user's cart
before adding the new item!
Arguments:
request (Request): The Django request object. This should contain
a JSON-serialized dictionary with "amount" (string, required),
and "course_id" (slash-separated course ID string, optional).
Returns:
HttpResponse: 200 on success with JSON-encoded dictionary that has keys
"payment_url" (string) and "payment_params" (dictionary). The client
should POST the payment params to the payment URL.
HttpResponse: 400 invalid amount or course ID.
HttpResponse: 404 donations are disabled.
HttpResponse: 405 invalid request method.
Example usage:
POST /shoppingcart/donation/
with params {'amount': '12.34', course_id': 'edX/DemoX/Demo_Course'}
will respond with the signed purchase params
that the client can send to the payment processor.
"""
amount = request.POST.get('amount')
course_id = request.POST.get('course_id')
# Check that required parameters are present and valid
if amount is None:
msg = u"Request is missing required param 'amount'"
log.error(msg)
return HttpResponseBadRequest(msg)
try:
amount = (
decimal.Decimal(amount)
).quantize(
decimal.Decimal('.01'),
rounding=decimal.ROUND_DOWN
)
except decimal.InvalidOperation:
return HttpResponseBadRequest("Could not parse 'amount' as a decimal")
# Any amount is okay as long as it's greater than 0
# Since we've already quantized the amount to 0.01
# and rounded down, we can check if it's less than 0.01
if amount < decimal.Decimal('0.01'):
return HttpResponseBadRequest("Amount must be greater than 0")
if course_id is not None:
try:
course_id = CourseLocator.from_string(course_id)
except InvalidKeyError:
msg = u"Request included an invalid course key: {course_key}".format(course_key=course_id)
log.error(msg)
return HttpResponseBadRequest(msg)
# Add the donation to the user's cart
cart = Order.get_cart_for_user(request.user)
cart.clear()
try:
# Course ID may be None if this is a donation to the entire organization
Donation.add_to_order(cart, amount, course_id=course_id)
except InvalidCartItem as ex:
log.exception((
u"Could not create donation item for "
u"amount '{amount}' and course ID '{course_id}'"
).format(amount=amount, course_id=course_id))
return HttpResponseBadRequest(unicode(ex))
# Start the purchase.
# This will "lock" the purchase so the user can't change
# the amount after we send the information to the payment processor.
# If the user tries to make another donation, it will be added
# to a new cart.
cart.start_purchase()
# Construct the response params (JSON-encoded)
callback_url = request.build_absolute_uri(
reverse("shoppingcart.views.postpay_callback")
)
response_params = json.dumps({
# The HTTP end-point for the payment processor.
"payment_url": get_purchase_endpoint(),
# Parameters the client should send to the payment processor
"payment_params": get_signed_purchase_params(
cart,
callback_url=callback_url,
extra_data=([unicode(course_id)] if course_id else None)
),
})
return HttpResponse(response_params, content_type="text/json")
@csrf_exempt
@require_POST
def postpay_callback(request):
......
......@@ -241,11 +241,12 @@ def create_order(request):
)
params = get_signed_purchase_params(
cart, callback_url=callback_url
cart,
callback_url=callback_url,
extra_data=[unicode(course_id)]
)
params['success'] = True
params['merchant_defined_data1'] = unicode(course_id)
return HttpResponse(json.dumps(params), content_type="text/json")
......
......@@ -1024,6 +1024,7 @@ main_vendor_js = base_vendor_js + [
'js/vendor/URI.min.js',
]
dashboard_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/dashboard/**/*.js'))
discussion_js = sorted(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/discussion/**/*.js'))
staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.js'))
open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_ended/**/*.js'))
......@@ -1173,6 +1174,10 @@ PIPELINE_JS = {
'source_filenames': instructor_dash_js,
'output_filename': 'js/instructor_dash.js',
},
'dashboard': {
'source_filenames': dashboard_js,
'output_filename': 'js/dashboard.js'
},
'student_account': {
'source_filenames': student_account_js,
'output_filename': 'js/student_account.js'
......
var edx = edx || {};
(function($) {
'use strict';
edx.dashboard = edx.dashboard || {};
edx.dashboard.donation = {};
/**
* View for making donations for a course.
* @constructor
* @param {Object} params
* @param {Object} params.el - The container element.
* @param {string} params.course - The ID of the course for the donation.
*/
edx.dashboard.donation.DonationView = function(params) {
/**
* Dynamically configure a form, which the client can submit
* to the payment processor.
* @param {Object} form - The form to modify.
* @param {string} method - The HTTP method used to submit the form.
* @param {string} url - The URL where the form data will be submitted.
* @param {Object} params - Form data, included as hidden inputs.
*/
var configureForm = function(form, method, url, params) {
$("input", form).remove();
form.attr("action", url);
form.attr("method", method);
_.each(params, function(value, key) {
$("<input>").attr({
type: "hidden",
name: key,
value: value
}).appendTo(form);
});
};
/**
* Fire an analytics event indicating that the user
* is about to be sent to the external payment processor.
*
* @param {string} course - The course ID for the donation.
*/
var firePaymentAnalyticsEvent = function(course) {
analytics.track(
"edx.bi.user.payment_processor.visited",
{
category: "donations",
label: course
}
);
};
/**
* Add a donation to the user's cart.
*
* @param {string} amount - The amount of the donation (e.g. "23.45")
* @param {string} course - The ID of the course.
* @returns {Object} The promise from the AJAX call to the server,
* which resolves with a data object of the form
* { payment_url: <string>, payment_params: <Object> }
*/
var addDonationToCart = function(amount, course) {
return $.ajax({
url: "/shoppingcart/donation/",
type: "POST",
data: {
amount: amount,
course_id: course
}
});
};
var view = {
/**
* Initialize the view.
*
* @param {Object} params
* @param {JQuery selector} params.el - The container element.
* @param {string} params.course - The ID of the course for the donation.
* @returns {DonationView}
*/
initialize: function(params) {
this.$el = params.el;
this.course = params.course;
_.bindAll(view,
'render', 'donate', 'startPayment',
'validate', 'startPayment',
'displayServerError', 'submitPaymentForm'
);
return this;
},
/**
* Render the form for making a donation for a course.
*
* @returns {DonationView}
*/
render: function() {
var html = _.template($("#donation-tpl").html(), {});
this.$el.html(html);
this.$amount = $("input[name=\"amount\"]", this.$el);
this.$submit = $("input[type=\"submit\"]", this.$el);
this.$errorMsg = $(".payment-form", this.$el);
this.$paymentForm = $(".payment-form", this.$el);
this.$submit.click(this.donate);
return this;
},
/**
* Handle a click event on the "donate" button.
* This will contact the LMS server to add the donation
* to the user's cart, then send the user to the
* external payment processor.
*
* @param {Object} event - The click event.
*/
donate: function(event) {
// Prevent form submission
if (event) {
event.preventDefault();
}
// Immediately disable the submit button to prevent duplicate submissions
this.$submit.addClass("disabled");
if (this.validate()) {
var amount = this.$amount.val();
addDonationToCart(amount, this.course)
.done(this.startPayment)
.fail(this.displayServerError);
}
else {
// If an error occurred, allow the user to resubmit
this.$submit.removeClass("disabled");
}
},
/**
* Send signed payment parameters to the external
* payment processor.
*
* @param {Object} data - The signed payment data received from the LMS server.
* @param {string} data.payment_url - The URL of the external payment processor.
* @param {Object} data.payment_data - Parameters to send to the external payment processor.
*/
startPayment: function(data) {
configureForm(
this.$paymentForm,
'post',
data.payment_url,
data.payment_params
);
firePaymentAnalyticsEvent(this.course);
this.submitPaymentForm(this.$paymentForm);
},
/**
* Validate the donation amount and mark any validation errors.
*
* @returns {boolean} True iff the form is valid.
*/
validate: function() {
var amount = this.$amount.val();
var isValid = this.validateAmount(amount);
if (isValid) {
this.$amount.removeClass('validation-error');
this.$errorMsg.text("");
}
else {
this.$amount.addClass('validation-error');
this.$errorMsg.text(
gettext("Please enter a valid donation amount.")
);
}
return isValid;
},
/**
* Validate that the given amount is a valid currency string.
*
* @param {string} amount
* @returns {boolean} True iff the amount is valid.
*/
validateAmount: function(amount) {
var amountRegex = /^\d+.\d{2}$|^\d+$/i;
if (!amountRegex.test(amount)) {
return false;
}
if (parseFloat(amount) < 0.01) {
return false;
}
return true;
},
/**
* Display an error message when we receive an error from the LMS server.
*/
displayServerError: function() {
// Display the error message
this.$errorMsg.text(gettext("Your donation could not be submitted."));
// Re-enable the submit button to allow the user to retry
this.$submit.removeClass("disabled");
},
/**
* Submit the payment from to the external payment processor.
* This is a separate function so we can easily stub it out in tests.
*
* @param {Object} form - The dynamically constructed payment form.
*/
submitPaymentForm: function(form) {
form.submit();
},
};
view.initialize(params);
return view;
};
$(document).ready(function() {
// There may be multiple donation forms on the page
// (one for each newly enrolled course).
// For each one, create a new donation view to handle
// that form, and parameterize it based on the
// "data-course" attribute (the course ID).
$(".donate-container").each(function() {
var container = $(this);
var course = container.data("course");
var view = new edx.dashboard.donation.DonationView({
el: container,
course: course
}).render();
});
});
})(jQuery);
\ No newline at end of file
../../../templates/dashboard/donation.underscore
\ No newline at end of file
define(['js/common_helpers/template_helpers', 'js/common_helpers/ajax_helpers', 'js/dashboard/donation'],
function(TemplateHelpers, AjaxHelpers) {
'use strict';
describe("edx.dashboard.donation.DonationView", function() {
var PAYMENT_URL = "https://fake.processor.com/pay/";
var PAYMENT_PARAMS = {
orderId: "test-order",
signature: "abcd1234"
};
var AMOUNT = "45.67";
var COURSE_ID = "edx/DemoX/Demo";
var view = null;
var requests = null;
beforeEach(function() {
setFixtures("<div></div>");
TemplateHelpers.installTemplate('templates/dashboard/donation');
view = new edx.dashboard.donation.DonationView({
el: $("#jasmine-fixtures"),
course: COURSE_ID
}).render();
// Stub out the actual submission of the payment form
// (which would cause the page to reload)
// This function gets passed the dynamically constructed
// form with signed payment parameters from the LMS server,
// so we can verify that the form is constructed correctly.
spyOn(view, 'submitPaymentForm').andCallFake(function() {});
// Stub the analytics event tracker
window.analytics = jasmine.createSpyObj('analytics', ['track']);
});
it("processes a donation for a course", function() {
// Spy on AJAX requests
requests = AjaxHelpers.requests(this);
// Enter a donation amount and proceed to the payment page
view.$amount.val(AMOUNT);
view.donate();
// Verify that the client contacts the server to create
// the donation item in the shopping cart and receive
// the signed payment params.
AjaxHelpers.expectRequest(
requests, "POST", "/shoppingcart/donation/",
$.param({ amount: AMOUNT, course_id: COURSE_ID })
);
// Simulate a response from the server containing the signed
// parameters to send to the payment processor
AjaxHelpers.respondWithJson(requests, {
payment_url: PAYMENT_URL,
payment_params: PAYMENT_PARAMS,
});
// Verify that the payment form has the payment parameters
// sent by the LMS server, and that it's targeted at the
// correct payment URL.
// We stub out the actual submission of the form to avoid
// leaving the current page during the test.
expect(view.submitPaymentForm).toHaveBeenCalled();
var form = view.submitPaymentForm.mostRecentCall.args[0];
expect(form.serialize()).toEqual($.param(PAYMENT_PARAMS));
expect(form.attr('method')).toEqual("post");
expect(form.attr('action')).toEqual(PAYMENT_URL);
});
it("validates the donation amount", function() {
var assertValidAmount = function(amount, isValid) {
expect(view.validateAmount(amount)).toBe(isValid);
};
assertValidAmount("", false);
assertValidAmount(" ", false);
assertValidAmount("abc", false);
assertValidAmount("14.", false);
assertValidAmount(".1", false);
assertValidAmount("-1", false);
assertValidAmount("-1.00", false);
assertValidAmount("-", false);
assertValidAmount("0", false);
assertValidAmount("0.00", false);
assertValidAmount("00.00", false);
assertValidAmount("3", true);
assertValidAmount("12.34", true);
assertValidAmount("278", true);
assertValidAmount("278.91", true);
assertValidAmount("0.14", true);
});
it("displays validation errors", function() {
// Attempt to submit an invalid donation amount
view.$amount.val("");
view.donate();
// Verify that the amount field is marked as having a validation error
expect(view.$amount).toHaveClass("validation-error");
// Verify that the error message appears
expect(view.$errorMsg.text()).toEqual("Please enter a valid donation amount.");
// Expect that the submit button is re-enabled to allow users to submit again
expect(view.$submit).not.toHaveClass("disabled");
// Try again, this time submitting a valid amount
view.$amount.val(AMOUNT);
view.donate();
// Expect that the errors are cleared
expect(view.$errorMsg.text()).toEqual("");
// Expect that the submit button is disabled
expect(view.$submit).toHaveClass("disabled");
});
it("displays an error when the server cannot be contacted", function() {
// Spy on AJAX requests
requests = AjaxHelpers.requests(this);
// Simulate an error from the LMS servers
view.donate();
AjaxHelpers.respondWithError(requests);
// Expect that the error is displayed
expect(view.$errorMsg.text()).toEqual("Your donation could not be submitted.");
// Verify that the submit button is re-enabled
// so users can try again.
expect(view.$submit).not.toHaveClass("disabled");
});
it("disables the submit button once the user donates", function() {
// Before we submit, the button should be enabled
expect(view.$submit).not.toHaveClass("disabled");
// Simulate starting a donation
// Since we're not simulating the AJAX response, this will block
// in the state just after the user kicks off the donation process.
view.donate();
// Verify that the submit button is disabled
expect(view.$submit).toHaveClass("disabled");
});
it("sends an analytics event when the user submits a donation", function() {
// Simulate the submission to the payment processor
// We skip the intermediary steps here by passing in
// the payment url and parameters,
// which the view would ordinarily retrieve from the LMS server.
view.startPayment({
payment_url: PAYMENT_URL,
payment_params: PAYMENT_PARAMS
});
// Verify that the analytics event was fired
expect(window.analytics.track).toHaveBeenCalledWith(
"edx.bi.user.payment_processor.visited",
{
category: "donations",
label: COURSE_ID
}
);
});
});
}
);
\ No newline at end of file
......@@ -219,6 +219,10 @@
exports: 'js/staff_debug_actions',
deps: ['gettext']
},
'js/dashboard/donation.js': {
exports: 'js/dashboard/donation',
deps: ['jquery', 'underscore', 'gettext']
},
// Backbone classes loaded explicitly until they are converted to use RequireJS
'js/models/cohort': {
exports: 'CohortModel',
......@@ -255,7 +259,8 @@
'lms/include/js/spec/views/cohorts_spec.js',
'lms/include/js/spec/photocapture_spec.js',
'lms/include/js/spec/staff_debug_actions_spec.js',
'lms/include/js/spec/views/notification_spec.js'
'lms/include/js/spec/views/notification_spec.js',
'lms/include/js/spec/dashboard/donation.js',
]);
}).call(this, requirejs, define);
......@@ -71,6 +71,7 @@ spec_paths:
#
fixture_paths:
- templates/instructor/instructor_dashboard_2
- templates/dashboard
requirejs:
paths:
......
......@@ -20,7 +20,16 @@
<%block name="bodyclass">view-dashboard is-authenticated</%block>
<%block name="nav_skip">#my-courses</%block>
<%block name="header_extras">
% for template_name in ["donation"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="dashboard/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="js_extra">
<%static:js group='dashboard'/>
<script type="text/javascript">
(function() {
......
<form class="donate-form">
<input type="text" name="amount" value="25.00" />
<input type="submit" name="Donate" value="<%- gettext('Donate') %>" />
<div class="donation-error-msg" />
</form>
<form class="payment-form"></form>
\ No newline at end of file
<%! from django.utils.translation import ugettext as _ %>
% for course, enrollment in recent_course_enrollment_pairs:
% for course_msg in course_enrollment_messages:
<div class="wrapper-msg urgency-high">
<div class="msg">
<div class="msg-content">
<h2 class="sr">${_("Enrollment Successful")}</h2>
<div class="copy">
<p>${_("You have successfully enrolled in {enrolled_course}.").format(enrolled_course=course.display_name)}</p>
<p>${_("You have successfully enrolled in {enrolled_course}.").format(enrolled_course=course_msg["course_name"])}</p>
% if course_msg["allow_donation"]:
<div class="donate-container" data-course="${ course_msg['course_id'] }" />
% endif
</div>
</div>
</div>
</div>
% endfor
% endfor
\ No newline at end of file
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