Merge pull request #5502 from edx/will/per-course-donation-button
Add donation button to the enrollment success message
This commit is contained in:
26
common/djangoapps/config_models/decorators.py
Normal file
26
common/djangoapps/config_models/decorators.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""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')
|
||||
@@ -127,6 +130,22 @@ class CourseMode(models.Model):
|
||||
# we prefer professional over verify
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
243
lms/static/js/dashboard/donation.js
Normal file
243
lms/static/js/dashboard/donation.js
Normal file
@@ -0,0 +1,243 @@
|
||||
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);
|
||||
1
lms/static/js/fixtures/donation.underscore
Symbolic link
1
lms/static/js/fixtures/donation.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/dashboard/donation.underscore
|
||||
170
lms/static/js/spec/dashboard/donation.js
Normal file
170
lms/static/js/spec/dashboard/donation.js
Normal file
@@ -0,0 +1,170 @@
|
||||
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
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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() {
|
||||
|
||||
|
||||
6
lms/templates/dashboard/donation.underscore
Normal file
6
lms/templates/dashboard/donation.underscore
Normal file
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
@@ -1,13 +1,17 @@
|
||||
<%! 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
|
||||
Reference in New Issue
Block a user