From f8365a2d3b342ad86c4ea252817c287372491197 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 3 Oct 2014 10:17:04 -0400 Subject: [PATCH] Add donation end-point Make donations configurable Added donation button to dashboard Generalize merchant defined data for payment processor --- common/djangoapps/config_models/decorators.py | 26 ++ common/djangoapps/course_modes/models.py | 19 ++ .../student/tests/test_recent_enrollments.py | 96 ++++--- common/djangoapps/student/views.py | 63 ++++- lms/djangoapps/shoppingcart/admin.py | 5 +- .../0019_auto__add_donationconfiguration.py | 199 ++++++++++++++ lms/djangoapps/shoppingcart/models.py | 10 +- .../shoppingcart/processors/CyberSource2.py | 24 +- .../shoppingcart/tests/test_models.py | 20 +- .../shoppingcart/tests/test_views.py | 152 +++++++++-- lms/djangoapps/shoppingcart/urls.py | 1 + lms/djangoapps/shoppingcart/views.py | 134 +++++++++- lms/djangoapps/verify_student/views.py | 5 +- lms/envs/common.py | 5 + lms/static/js/dashboard/donation.js | 243 ++++++++++++++++++ lms/static/js/fixtures/donation.underscore | 1 + lms/static/js/spec/dashboard/donation.js | 170 ++++++++++++ lms/static/js/spec/main.js | 7 +- lms/static/js_test.yml | 1 + lms/templates/dashboard.html | 9 + lms/templates/dashboard/donation.underscore | 6 + .../enrollment/course_enrollment_message.html | 10 +- 22 files changed, 1101 insertions(+), 105 deletions(-) create mode 100644 common/djangoapps/config_models/decorators.py create mode 100644 lms/djangoapps/shoppingcart/migrations/0019_auto__add_donationconfiguration.py create mode 100644 lms/static/js/dashboard/donation.js create mode 120000 lms/static/js/fixtures/donation.underscore create mode 100644 lms/static/js/spec/dashboard/donation.js create mode 100644 lms/templates/dashboard/donation.underscore diff --git a/common/djangoapps/config_models/decorators.py b/common/djangoapps/config_models/decorators.py new file mode 100644 index 0000000000..58fc3b9ee7 --- /dev/null +++ b/common/djangoapps/config_models/decorators.py @@ -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 diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 2bd63ccf0d..42fb5970e8 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -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): """ diff --git a/common/djangoapps/student/tests/test_recent_enrollments.py b/common/djangoapps/student/tests/test_recent_enrollments.py index f7b341361c..ac8c58fbe5 100644 --- a/common/djangoapps/student/tests/test_recent_enrollments.py +++ b/common/djangoapps/student/tests/test_recent_enrollments.py @@ -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") diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 2fecaf66da..c6813268f5 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -412,7 +412,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 @@ -421,7 +421,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 @@ -472,9 +474,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) @@ -496,8 +506,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( @@ -567,7 +590,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, @@ -595,23 +618,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} ) @@ -624,14 +659,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 diff --git a/lms/djangoapps/shoppingcart/admin.py b/lms/djangoapps/shoppingcart/admin.py index 841ea7e96d..d2d9195777 100644 --- a/lms/djangoapps/shoppingcart/admin.py +++ b/lms/djangoapps/shoppingcart/admin.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/migrations/0019_auto__add_donationconfiguration.py b/lms/djangoapps/shoppingcart/migrations/0019_auto__add_donationconfiguration.py new file mode 100644 index 0000000000..8970f82b83 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0019_auto__add_donationconfiguration.py @@ -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'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 30e823b671..bbe15188ec 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource2.py b/lms/djangoapps/shoppingcart/processors/CyberSource2.py index fb9a5cb469..efd0b8f530 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource2.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource2.py @@ -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 diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 469ec187a1..080f1a5089 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 5a17c9c214..8ec870ea1f 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -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): diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 1e1944b07b..6d5865f93d 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -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[0-9]*)/$', 'show_receipt'), + url(r'^donation/$', 'donate', name='donation'), url(r'^csv_report/$', 'csv_report', name='payment_csv_report'), ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 8c729a142a..869ebea6a0 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -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): diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index f055fea197..04e1d4c809 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -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") diff --git a/lms/envs/common.py b/lms/envs/common.py index 294dd4765d..da04358244 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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' diff --git a/lms/static/js/dashboard/donation.js b/lms/static/js/dashboard/donation.js new file mode 100644 index 0000000000..d4f25c38ac --- /dev/null +++ b/lms/static/js/dashboard/donation.js @@ -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) { + $("").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: , payment_params: } + */ + var addDonationToCart = function(amount, course) { + return $.ajax({ + url: "/shoppingcart/donation/", + type: "POST", + data: { + amount: amount, + course_id: course + } + }); + }; + + var view = { + /** + * Initialize the view. + * + * @param {Object} params + * @param {JQuery selector} params.el - The container element. + * @param {string} params.course - The ID of the course for the donation. + * @returns {DonationView} + */ + initialize: function(params) { + this.$el = params.el; + this.course = params.course; + _.bindAll(view, + 'render', 'donate', 'startPayment', + 'validate', 'startPayment', + 'displayServerError', 'submitPaymentForm' + ); + return this; + }, + + /** + * Render the form for making a donation for a course. + * + * @returns {DonationView} + */ + render: function() { + var html = _.template($("#donation-tpl").html(), {}); + this.$el.html(html); + this.$amount = $("input[name=\"amount\"]", this.$el); + this.$submit = $("input[type=\"submit\"]", this.$el); + this.$errorMsg = $(".payment-form", this.$el); + this.$paymentForm = $(".payment-form", this.$el); + this.$submit.click(this.donate); + return this; + }, + + /** + * Handle a click event on the "donate" button. + * This will contact the LMS server to add the donation + * to the user's cart, then send the user to the + * external payment processor. + * + * @param {Object} event - The click event. + */ + donate: function(event) { + // Prevent form submission + if (event) { + event.preventDefault(); + } + + // Immediately disable the submit button to prevent duplicate submissions + this.$submit.addClass("disabled"); + + if (this.validate()) { + var amount = this.$amount.val(); + addDonationToCart(amount, this.course) + .done(this.startPayment) + .fail(this.displayServerError); + } + else { + // If an error occurred, allow the user to resubmit + this.$submit.removeClass("disabled"); + } + }, + + /** + * Send signed payment parameters to the external + * payment processor. + * + * @param {Object} data - The signed payment data received from the LMS server. + * @param {string} data.payment_url - The URL of the external payment processor. + * @param {Object} data.payment_data - Parameters to send to the external payment processor. + */ + startPayment: function(data) { + configureForm( + this.$paymentForm, + 'post', + data.payment_url, + data.payment_params + ); + firePaymentAnalyticsEvent(this.course); + this.submitPaymentForm(this.$paymentForm); + }, + + /** + * Validate the donation amount and mark any validation errors. + * + * @returns {boolean} True iff the form is valid. + */ + validate: function() { + var amount = this.$amount.val(); + var isValid = this.validateAmount(amount); + + if (isValid) { + this.$amount.removeClass('validation-error'); + this.$errorMsg.text(""); + } + + else { + this.$amount.addClass('validation-error'); + this.$errorMsg.text( + gettext("Please enter a valid donation amount.") + ); + } + + return isValid; + }, + + /** + * Validate that the given amount is a valid currency string. + * + * @param {string} amount + * @returns {boolean} True iff the amount is valid. + */ + validateAmount: function(amount) { + var amountRegex = /^\d+.\d{2}$|^\d+$/i; + if (!amountRegex.test(amount)) { + return false; + } + + if (parseFloat(amount) < 0.01) { + return false; + } + + return true; + }, + + /** + * Display an error message when we receive an error from the LMS server. + */ + displayServerError: function() { + // Display the error message + this.$errorMsg.text(gettext("Your donation could not be submitted.")); + + // Re-enable the submit button to allow the user to retry + this.$submit.removeClass("disabled"); + }, + + /** + * Submit the payment from to the external payment processor. + * This is a separate function so we can easily stub it out in tests. + * + * @param {Object} form - The dynamically constructed payment form. + */ + submitPaymentForm: function(form) { + form.submit(); + }, + }; + + view.initialize(params); + return view; + }; + + $(document).ready(function() { + // There may be multiple donation forms on the page + // (one for each newly enrolled course). + // For each one, create a new donation view to handle + // that form, and parameterize it based on the + // "data-course" attribute (the course ID). + $(".donate-container").each(function() { + var container = $(this); + var course = container.data("course"); + var view = new edx.dashboard.donation.DonationView({ + el: container, + course: course + }).render(); + }); + }); + +})(jQuery); \ No newline at end of file diff --git a/lms/static/js/fixtures/donation.underscore b/lms/static/js/fixtures/donation.underscore new file mode 120000 index 0000000000..3b0e2d7ec0 --- /dev/null +++ b/lms/static/js/fixtures/donation.underscore @@ -0,0 +1 @@ +../../../templates/dashboard/donation.underscore \ No newline at end of file diff --git a/lms/static/js/spec/dashboard/donation.js b/lms/static/js/spec/dashboard/donation.js new file mode 100644 index 0000000000..f67e7ae502 --- /dev/null +++ b/lms/static/js/spec/dashboard/donation.js @@ -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("
"); + TemplateHelpers.installTemplate('templates/dashboard/donation'); + + view = new edx.dashboard.donation.DonationView({ + el: $("#jasmine-fixtures"), + course: COURSE_ID + }).render(); + + // Stub out the actual submission of the payment form + // (which would cause the page to reload) + // This function gets passed the dynamically constructed + // form with signed payment parameters from the LMS server, + // so we can verify that the form is constructed correctly. + spyOn(view, 'submitPaymentForm').andCallFake(function() {}); + + // Stub the analytics event tracker + window.analytics = jasmine.createSpyObj('analytics', ['track']); + }); + + it("processes a donation for a course", function() { + // Spy on AJAX requests + requests = AjaxHelpers.requests(this); + + // Enter a donation amount and proceed to the payment page + view.$amount.val(AMOUNT); + view.donate(); + + // Verify that the client contacts the server to create + // the donation item in the shopping cart and receive + // the signed payment params. + AjaxHelpers.expectRequest( + requests, "POST", "/shoppingcart/donation/", + $.param({ amount: AMOUNT, course_id: COURSE_ID }) + ); + + // Simulate a response from the server containing the signed + // parameters to send to the payment processor + AjaxHelpers.respondWithJson(requests, { + payment_url: PAYMENT_URL, + payment_params: PAYMENT_PARAMS, + }); + + // Verify that the payment form has the payment parameters + // sent by the LMS server, and that it's targeted at the + // correct payment URL. + // We stub out the actual submission of the form to avoid + // leaving the current page during the test. + expect(view.submitPaymentForm).toHaveBeenCalled(); + var form = view.submitPaymentForm.mostRecentCall.args[0]; + expect(form.serialize()).toEqual($.param(PAYMENT_PARAMS)); + expect(form.attr('method')).toEqual("post"); + expect(form.attr('action')).toEqual(PAYMENT_URL); + }); + + it("validates the donation amount", function() { + var assertValidAmount = function(amount, isValid) { + expect(view.validateAmount(amount)).toBe(isValid); + }; + assertValidAmount("", false); + assertValidAmount(" ", false); + assertValidAmount("abc", false); + assertValidAmount("14.", false); + assertValidAmount(".1", false); + assertValidAmount("-1", false); + assertValidAmount("-1.00", false); + assertValidAmount("-", false); + assertValidAmount("0", false); + assertValidAmount("0.00", false); + assertValidAmount("00.00", false); + assertValidAmount("3", true); + assertValidAmount("12.34", true); + assertValidAmount("278", true); + assertValidAmount("278.91", true); + assertValidAmount("0.14", true); + }); + + it("displays validation errors", function() { + // Attempt to submit an invalid donation amount + view.$amount.val(""); + view.donate(); + + // Verify that the amount field is marked as having a validation error + expect(view.$amount).toHaveClass("validation-error"); + + // Verify that the error message appears + expect(view.$errorMsg.text()).toEqual("Please enter a valid donation amount."); + + // Expect that the submit button is re-enabled to allow users to submit again + expect(view.$submit).not.toHaveClass("disabled"); + + // Try again, this time submitting a valid amount + view.$amount.val(AMOUNT); + view.donate(); + + // Expect that the errors are cleared + expect(view.$errorMsg.text()).toEqual(""); + + // Expect that the submit button is disabled + expect(view.$submit).toHaveClass("disabled"); + }); + + it("displays an error when the server cannot be contacted", function() { + // Spy on AJAX requests + requests = AjaxHelpers.requests(this); + + // Simulate an error from the LMS servers + view.donate(); + AjaxHelpers.respondWithError(requests); + + // Expect that the error is displayed + expect(view.$errorMsg.text()).toEqual("Your donation could not be submitted."); + + // Verify that the submit button is re-enabled + // so users can try again. + expect(view.$submit).not.toHaveClass("disabled"); + }); + + it("disables the submit button once the user donates", function() { + // Before we submit, the button should be enabled + expect(view.$submit).not.toHaveClass("disabled"); + + // Simulate starting a donation + // Since we're not simulating the AJAX response, this will block + // in the state just after the user kicks off the donation process. + view.donate(); + + // Verify that the submit button is disabled + expect(view.$submit).toHaveClass("disabled"); + }); + + it("sends an analytics event when the user submits a donation", function() { + // Simulate the submission to the payment processor + // We skip the intermediary steps here by passing in + // the payment url and parameters, + // which the view would ordinarily retrieve from the LMS server. + view.startPayment({ + payment_url: PAYMENT_URL, + payment_params: PAYMENT_PARAMS + }); + + // Verify that the analytics event was fired + expect(window.analytics.track).toHaveBeenCalledWith( + "edx.bi.user.payment_processor.visited", + { + category: "donations", + label: COURSE_ID + } + ); + }); + }); + } +); \ No newline at end of file diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index d68407d3bc..d30440d4d9 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -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); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index d9716535d8..dbc7f00e07 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -71,6 +71,7 @@ spec_paths: # fixture_paths: - templates/instructor/instructor_dashboard_2 + - templates/dashboard requirejs: paths: diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 23dac72409..78d1aa7a49 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -20,7 +20,16 @@ <%block name="bodyclass">view-dashboard is-authenticated <%block name="nav_skip">#my-courses +<%block name="header_extras"> +% for template_name in ["donation"]: + +% endfor + + <%block name="js_extra"> + <%static:js group='dashboard'/>