Merge pull request #5502 from edx/will/per-course-donation-button
Add donation button to the enrollment success message
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user