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

Add donation button to the enrollment success message
This commit is contained in:
Will Daly
2014-10-08 06:05:37 -04:00
22 changed files with 1101 additions and 105 deletions

View File

@@ -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)

View File

@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'DonationConfiguration'
db.create_table('shoppingcart_donationconfiguration', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
))
db.send_create_signal('shoppingcart', ['DonationConfiguration'])
def backwards(self, orm):
# Deleting model 'DonationConfiguration'
db.delete_table('shoppingcart_donationconfiguration')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'shoppingcart.certificateitem': {
'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']},
'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}),
'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}),
'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'})
},
'shoppingcart.coupon': {
'Meta': {'object_name': 'Coupon'},
'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 10, 3, 0, 0)'}),
'created_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'percentage_discount': ('django.db.models.fields.IntegerField', [], {'default': '0'})
},
'shoppingcart.couponredemption': {
'Meta': {'object_name': 'CouponRedemption'},
'coupon': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Coupon']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'shoppingcart.courseregistrationcode': {
'Meta': {'object_name': 'CourseRegistrationCode'},
'code': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 10, 3, 0, 0)'}),
'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user'", 'to': "orm['auth.User']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Invoice']", 'null': 'True'}),
'order': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'purchase_order'", 'null': 'True', 'to': "orm['shoppingcart.Order']"})
},
'shoppingcart.donation': {
'Meta': {'object_name': 'Donation', '_ormbases': ['shoppingcart.OrderItem']},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'donation_type': ('django.db.models.fields.CharField', [], {'default': "'general'", 'max_length': '32'}),
'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'})
},
'shoppingcart.donationconfiguration': {
'Meta': {'object_name': 'DonationConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'shoppingcart.invoice': {
'Meta': {'object_name': 'Invoice'},
'address_line_1': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'address_line_2': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}),
'address_line_3': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}),
'city': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}),
'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'internal_reference': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}),
'is_valid': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'state': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}),
'total_amount': ('django.db.models.fields.FloatField', [], {}),
'zip': ('django.db.models.fields.CharField', [], {'max_length': '15', 'null': 'True'})
},
'shoppingcart.order': {
'Meta': {'object_name': 'Order'},
'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}),
'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}),
'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}),
'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
'refunded_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'shoppingcart.orderitem': {
'Meta': {'object_name': 'OrderItem'},
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}),
'list_price': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '30', 'decimal_places': '2'}),
'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}),
'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}),
'service_fee': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32', 'db_index': 'True'}),
'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'shoppingcart.paidcourseregistration': {
'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}),
'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}),
'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'})
},
'shoppingcart.paidcourseregistrationannotation': {
'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'},
'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'shoppingcart.registrationcoderedemption': {
'Meta': {'object_name': 'RegistrationCodeRedemption'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']", 'null': 'True'}),
'redeemed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 10, 3, 0, 0)', 'null': 'True'}),
'redeemed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'registration_code': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.CourseRegistrationCode']"})
},
'student.courseenrollment': {
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['shoppingcart']

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -1,9 +1,11 @@
"""
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.
COST = Decimal('23.45')
These tests cover both the single-item purchase flow,
as well as the receipt page for donation items.
"""
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):

View File

@@ -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'),
)

View File

@@ -1,10 +1,13 @@
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):

View File

@@ -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")