Commit 26bcfe58 by Will Daly

Create Donation order item

Add tax-deductible messaging to the confirmation email and receipt pages.
Update receipt page to make the course URL optional
parent 4e796c57
......@@ -30,9 +30,12 @@ from xmodule_django.models import CourseKeyField
from verify_student.models import SoftwareSecurePhotoVerification
from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException,
AlreadyEnrolledInCourseException, CourseDoesNotExistException,
MultipleCouponsNotAllowedException, RegCodeAlreadyExistException, ItemDoesNotExistAgainstRegCodeException)
from .exceptions import (
InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException,
AlreadyEnrolledInCourseException, CourseDoesNotExistException,
MultipleCouponsNotAllowedException, RegCodeAlreadyExistException,
ItemDoesNotExistAgainstRegCodeException
)
from microsite_configuration import microsite
......@@ -865,3 +868,140 @@ class CertificateItem(OrderItem):
mode='verified',
status='purchased',
unit_cost__gt=(CourseMode.min_course_price_for_verified_for_currency(course_id, 'usd')))).count()
class Donation(OrderItem):
"""A donation made by a user.
Donations can be made for a specific course or to the organization as a whole.
Users can choose the donation amount.
"""
# Types of donations
DONATION_TYPES = (
("general", "A general donation"),
("course", "A donation to a particular course")
)
# The type of donation
donation_type = models.CharField(max_length=32, default="general", choices=DONATION_TYPES)
# If a donation is made for a specific course, then store the course ID here.
# If the donation is made to the organization as a whole,
# set this field to CourseKeyField.Empty
course_id = CourseKeyField(max_length=255, db_index=True)
@classmethod
@transaction.commit_on_success
def add_to_order(cls, order, donation_amount, course_id=None, currency='usd'):
"""Add a donation to an order.
Args:
order (Order): The order to add this donation to.
donation_amount (Decimal): The amount the user is donating.
Keyword Args:
course_id (CourseKey): If provided, associate this donation with a particular course.
currency (str): The currency used for the the donation.
Raises:
InvalidCartItem: The provided course ID is not valid.
Returns:
Donation
"""
# This will validate the currency but won't actually add the item to the order.
super(Donation, cls).add_to_order(order, currency=currency)
# Create a line item description, including the name of the course
# if this is a per-course donation.
# This will raise an exception if the course can't be found.
description = cls._line_item_description(course_id=course_id)
params = {
"order": order,
"user": order.user,
"status": order.status,
"qty": 1,
"unit_cost": donation_amount,
"currency": currency,
"line_desc": description
}
if course_id is not None:
params["course_id"] = course_id
params["donation_type"] = "course"
else:
params["donation_type"] = "general"
return cls.objects.create(**params)
def purchased_callback(self):
"""Donations do not need to be fulfilled, so this method does nothing."""
pass
def generate_receipt_instructions(self):
"""Provide information about tax-deductible donations in the receipt.
Returns:
tuple of (Donation, unicode)
"""
return self.pk_with_subclass, set([self._tax_deduction_msg()])
@property
def additional_instruction_text(self):
"""Provide information about tax-deductible donations in the confirmation email.
Returns:
unicode
"""
return self._tax_deduction_msg()
def _tax_deduction_msg(self):
"""Return the translated version of the tax deduction message.
Returns:
unicode
"""
return _(
u"This receipt was prepared to support charitable contributions for tax purposes. "
u"Gifts are tax deductible as permitted by law. "
u"We confirm that neither goods nor services were provided in exchange for this gift."
)
@classmethod
def _line_item_description(self, course_id=None):
"""Create a line-item description for the donation.
Includes the course display name if provided.
Keyword Arguments:
course_id (CourseKey)
Raises:
InvalidCartItem: The course ID is not valid.
Returns:
unicode
"""
# If a course ID is provided, include the display name of the course
# in the line item description.
if course_id is not None:
course = modulestore().get_course(course_id)
if course is None:
err = _(
u"Could not find a course with the ID '{course_id}'"
).format(course_id=course_id)
raise InvalidCartItem(err)
return _(u"Donation for {course}").format(course=course.display_name)
# The donation is for the organization as a whole, not a specific course
else:
return _(u"Donation")
"""
Tests for the Shopping Cart Models
"""
from decimal import Decimal
import datetime
import smtplib
from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors
from mock import patch, MagicMock
import pytz
from django.core import mail
from django.conf import settings
from django.db import DatabaseError
from django.test import TestCase
from django.test.utils import override_settings
from django.contrib.auth.models import AnonymousUser
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from shoppingcart.models import (Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration,
OrderItemSubclassPK)
from shoppingcart.models import (
Order, OrderItem, CertificateItem,
InvalidCartItem, PaidCourseRegistration,
Donation, OrderItemSubclassPK
)
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from shoppingcart.exceptions import PurchasedCallbackException
import pytz
import datetime
from opaque_keys.edx.locations import SlashSeparatedCourseKey
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class OrderTest(ModuleStoreTestCase):
def setUp(self):
self.user = UserFactory.create()
......@@ -286,7 +296,7 @@ class OrderItemTest(TestCase):
self.assertEquals(set([]), inst_set)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class PaidCourseRegistrationTest(ModuleStoreTestCase):
def setUp(self):
self.user = UserFactory.create()
......@@ -383,7 +393,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
self.assertTrue(PaidCourseRegistration.contained_in_order(cart, self.course_key))
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class CertificateItemTest(ModuleStoreTestCase):
"""
Tests for verifying specific CertificateItem functionality
......@@ -547,3 +557,80 @@ class CertificateItemTest(ModuleStoreTestCase):
CourseEnrollment.enroll(self.user, self.course_key, 'verified')
ret_val = CourseEnrollment.unenroll(self.user, self.course_key)
self.assertFalse(ret_val)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class DonationTest(ModuleStoreTestCase):
"""Tests for the donation order item type. """
COST = Decimal('23.45')
def setUp(self):
"""Create a test user and order. """
super(DonationTest, self).setUp()
self.user = UserFactory.create()
self.cart = Order.get_cart_for_user(self.user)
def test_donate_to_org(self):
# No course ID provided, so this is a donation to the entire organization
donation = Donation.add_to_order(self.cart, self.COST)
self._assert_donation(
donation,
donation_type="general",
unit_cost=self.COST,
line_desc="Donation"
)
def test_donate_to_course(self):
# Create a test course
course = CourseFactory.create(display_name="Test Course")
# Donate to the course
donation = Donation.add_to_order(self.cart, self.COST, course_id=course.id)
self._assert_donation(
donation,
donation_type="course",
course_id=course.id,
unit_cost=self.COST,
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)
self.cart.start_purchase()
self.cart.purchase()
# Check that the tax-deduction information appears in the confirmation email
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertEquals('Order Payment Confirmation', email.subject)
self.assertIn("tax deductible", email.body)
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)
self.assertEqual(donation.user, self.user)
self.assertEqual(donation.donation_type, donation_type)
self.assertEqual(donation.course_id, course_id)
self.assertEqual(donation.qty, 1)
self.assertEqual(donation.unit_cost, unit_cost)
self.assertEqual(donation.currency, "usd")
self.assertEqual(donation.line_desc, line_desc)
# Verify that the donation is in the cart
self.assertTrue(self.cart.has_items(item_type=Donation))
self.assertEqual(self.cart.total_cost, unit_cost)
# Purchase the item
self.cart.start_purchase()
self.cart.purchase()
# Verify that the donation is marked as purchased
donation = Donation.objects.get(pk=donation.id)
self.assertEqual(donation.status, "purchased")
......@@ -18,11 +18,16 @@ from pytz import UTC
from freezegun import freeze_time
from datetime import datetime, timedelta
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from shoppingcart.views import _can_download_report, _get_date_from_str
from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration, Coupon, CourseRegistrationCode, RegistrationCodeRedemption
from shoppingcart.models import (
Order, CertificateItem, PaidCourseRegistration,
Coupon, CourseRegistrationCode, RegistrationCodeRedemption,
Donation
)
from student.tests.factories import UserFactory, AdminFactory
from courseware.tests.factories import InstructorFactory
from student.models import CourseEnrollment
......@@ -33,7 +38,7 @@ from shoppingcart.admin import SoftDeleteCouponAdmin
from mock import patch, Mock
from shoppingcart.views import initialize_report
from decimal import Decimal
from student.tests.factories import AdminFactory
def mock_render_purchase_form_html(*args, **kwargs):
return render_purchase_form_html(*args, **kwargs)
......@@ -48,7 +53,12 @@ render_mock = Mock(side_effect=mock_render_to_response)
postpay_mock = Mock()
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class ShoppingCartViewsTests(ModuleStoreTestCase):
def setUp(self):
patcher = patch('student.models.tracker')
......@@ -739,7 +749,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
self.assertEqual(template, cert_item.single_item_receipt_template)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class RegistrationCodeRedemptionCourseEnrollment(ModuleStoreTestCase):
"""
Test suite for RegistrationCodeRedemption Course Enrollments
......@@ -857,7 +867,57 @@ class RegistrationCodeRedemptionCourseEnrollment(ModuleStoreTestCase):
self.assertTrue("You've clicked a link for an enrollment code that has already been used." in response.content)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class DonationReceiptViewTest(ModuleStoreTestCase):
"""Tests for the receipt page when the user pays for a donation. """
COST = Decimal('23.45')
PASSWORD = "password"
def setUp(self):
"""Create a test user and order. """
super(DonationReceiptViewTest, self).setUp()
# Create and login a user
self.user = UserFactory.create()
self.user.set_password(self.PASSWORD)
self.user.save()
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)
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
self._assert_receipt_contains("tax deductible")
def test_donation_for_course_receipt(self):
# Create a test course
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()
# Verify the receipt page
self._assert_receipt_contains("tax deductible")
self._assert_receipt_contains(self.course.display_name)
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)
self.assertContains(resp, expected_text)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class CSVReportViewsTest(ModuleStoreTestCase):
"""
Test suite for CSV Purchase Reporting
......
......@@ -46,13 +46,17 @@
</tr>
% for item in order_items:
<% course_id = reverse('info', args=[item.course_id.to_deprecated_string()]) %>
<tr class="order-item">
% if item.status == "purchased":
<td>${item.qty}</td>
<td>${item.line_desc}</td>
<td><a href="${course_id | h}" class="enter-course">${_('View Course')}</a></td>
<td>
% if item.course_id:
<% course_id = reverse('info', args=[item.course_id.to_deprecated_string()]) %>
<a href="${course_id | h}" class="enter-course">${_('View Course')}</a></td>
% endif
</td>
<td>${"{0:0.2f}".format(item.unit_cost)}
% if item.list_price != None:
<span class="old-price"> ${"{0:0.2f}".format(item.list_price)}</span>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment