diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py
index 66f349b7da..c8b789aa93 100644
--- a/common/djangoapps/course_modes/models.py
+++ b/common/djangoapps/course_modes/models.py
@@ -177,13 +177,6 @@ class CourseMode(models.Model):
# Modes that are allowed to upsell
UPSELL_TO_VERIFIED_MODES = [HONOR, AUDIT]
- # Courses purchased through the shoppingcart
- # should be "honor". Since we've changed the DEFAULT_MODE_SLUG from
- # "honor" to "audit", we still need to have the shoppingcart
- # use "honor"
- DEFAULT_SHOPPINGCART_MODE_SLUG = HONOR
- DEFAULT_SHOPPINGCART_MODE = Mode(HONOR, _('Honor'), 0, '', 'usd', None, None, None, None)
-
CACHE_NAMESPACE = u"course_modes.CourseMode.cache."
class Meta(object):
diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py
index 366c138d02..f6977ef558 100644
--- a/common/djangoapps/course_modes/tests/test_views.py
+++ b/common/djangoapps/course_modes/tests/test_views.py
@@ -19,10 +19,10 @@ from mock import patch
from course_modes.models import CourseMode, Mode
from course_modes.tests.factories import CourseModeFactory
from lms.djangoapps.commerce.tests import test_utils as ecomm_test_utils
+from lms.djangoapps.commerce.tests.mocks import mock_payment_processors
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.embargo.test_utils import restrict_course
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
-from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from student.models import CourseEnrollment
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from util.testing import UrlResetMixin
@@ -119,7 +119,8 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
# Check whether we were correctly redirected
purchase_workflow = "?purchase_workflow=single"
start_flow_url = reverse('verify_student_start_flow', args=[six.text_type(self.course.id)]) + purchase_workflow
- self.assertRedirects(response, start_flow_url)
+ with mock_payment_processors():
+ self.assertRedirects(response, start_flow_url)
def test_no_id_redirect_otto(self):
# Create the course modes
@@ -267,7 +268,8 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
# we're redirected immediately to the start of the payment flow.
purchase_workflow = "?purchase_workflow=single"
start_flow_url = reverse('verify_student_start_flow', args=[six.text_type(self.course.id)]) + purchase_workflow
- self.assertRedirects(response, start_flow_url)
+ with mock_payment_processors():
+ self.assertRedirects(response, start_flow_url)
# Now enroll in the course
CourseEnrollmentFactory(
@@ -317,7 +319,8 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
else:
self.fail("Must provide a valid redirect URL name")
- self.assertRedirects(response, redirect_url)
+ with mock_payment_processors(expect_called=None):
+ self.assertRedirects(response, redirect_url)
def test_choose_mode_audit_enroll_on_post(self):
audit_mode = 'audit'
diff --git a/common/djangoapps/util/tests/test_db.py b/common/djangoapps/util/tests/test_db.py
index 108b66a827..7bb263c237 100644
--- a/common/djangoapps/util/tests/test_db.py
+++ b/common/djangoapps/util/tests/test_db.py
@@ -198,6 +198,9 @@ class MigrationTests(TestCase):
"""
@override_settings(MIGRATION_MODULES={})
+ @unittest.skip(
+ "Temporary skip for https://openedx.atlassian.net/browse/DEPR-43 where shoppingcart models are to be removed"
+ )
def test_migrations_are_in_sync(self):
"""
Tests that the migration files are in sync with the models.
diff --git a/docs/guides/docstrings/lms_index.rst b/docs/guides/docstrings/lms_index.rst
index f771e3dc50..32df0e0b58 100644
--- a/docs/guides/docstrings/lms_index.rst
+++ b/docs/guides/docstrings/lms_index.rst
@@ -20,5 +20,4 @@ Studio.
lms/djangoapps/mobile_api/modules
lms/djangoapps/notes/modules
lms/djangoapps/rss_proxy/modules
- lms/djangoapps/shoppingcart/modules
lms/djangoapps/survey/modules
diff --git a/lms/devstack.yml b/lms/devstack.yml
index 4eb59456c2..40d8f682ac 100644
--- a/lms/devstack.yml
+++ b/lms/devstack.yml
@@ -101,19 +101,6 @@ CACHES:
CAS_ATTRIBUTE_CALLBACK: ''
CAS_EXTRA_LOGIN_PARAMS: ''
CAS_SERVER_URL: ''
-CC_PROCESSOR:
- CyberSource:
- MERCHANT_ID: ''
- ORDERPAGE_VERSION: '7'
- PURCHASE_ENDPOINT: ''
- SERIAL_NUMBER: ''
- SHARED_SECRET: ''
- CyberSource2:
- ACCESS_KEY: ''
- PROFILE_ID: ''
- PURCHASE_ENDPOINT: ''
- SECRET_KEY: ''
-CC_PROCESSOR_NAME: CyberSource2
CELERY_BROKER_HOSTNAME: localhost
CELERY_BROKER_PASSWORD: celery
CELERY_BROKER_TRANSPORT: amqp
diff --git a/lms/djangoapps/commerce/tests/mocks.py b/lms/djangoapps/commerce/tests/mocks.py
index 4b0813f992..0abdd46e70 100644
--- a/lms/djangoapps/commerce/tests/mocks.py
+++ b/lms/djangoapps/commerce/tests/mocks.py
@@ -33,7 +33,8 @@ class mock_ecommerce_api_endpoint(object):
response: a JSON-serializable Python type representing the desired response body.
status: desired HTTP status for the response.
expect_called: a boolean indicating whether an API request was expected; set
- to False if we should ensure that no request arrived.
+ to False if we should ensure that no request arrived, or None to skip checking
+ if the request arrived
exception: raise this exception instead of returning an HTTP response when called.
reset_on_exit (bool): Indicates if `httpretty` should be reset after the decorator exits.
"""
@@ -75,7 +76,10 @@ class mock_ecommerce_api_endpoint(object):
)
def __exit__(self, exc_type, exc_val, exc_tb):
- called_if_expected = self.expect_called == (httpretty.last_request().headers != {})
+ if self.expect_called is None:
+ called_if_expected = True
+ else:
+ called_if_expected = self.expect_called == (httpretty.last_request().headers != {})
httpretty.disable()
if self.reset_on_exit:
@@ -108,6 +112,18 @@ class mock_create_refund(mock_ecommerce_api_endpoint):
return '/refunds/'
+class mock_payment_processors(mock_ecommerce_api_endpoint):
+ """
+ Mocks calls to E-Commerce API payment processors method.
+ """
+
+ default_response = ['foo', 'bar']
+ method = httpretty.GET
+
+ def get_path(self):
+ return "/payment/processors/"
+
+
class mock_process_refund(mock_ecommerce_api_endpoint):
""" Mocks calls to E-Commerce API client refund process method. """
diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py
index 19f22900f3..74a821236f 100644
--- a/lms/djangoapps/instructor/enrollment.py
+++ b/lms/djangoapps/instructor/enrollment.py
@@ -141,12 +141,12 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal
# if the student is currently unenrolled, don't enroll them in their
# previous mode
- # for now, White Labels use 'shoppingcart' which is based on the
+ # for now, White Labels use the
# "honor" course_mode. Given the change to use "audit" as the default
# course_mode in Open edX, we need to be backwards compatible with
# how White Labels approach enrollment modes.
if CourseMode.is_white_label(course_id):
- course_mode = CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG
+ course_mode = CourseMode.HONOR
else:
course_mode = None
diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py
index 98a7563cf6..9ae520b4f8 100644
--- a/lms/djangoapps/instructor/tests/test_api.py
+++ b/lms/djangoapps/instructor/tests/test_api.py
@@ -2522,7 +2522,6 @@ class TestInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTe
@ddt.ddt
-@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test endpoints that show data without side effects.
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index 904d8dfe7d..6caa9e2a8c 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -325,10 +325,10 @@ def register_and_enroll_students(request, course_id): # pylint: disable=too-man
row_errors = []
general_errors = []
- # for white labels we use 'shopping cart' which uses CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG as
+ # for white labels we use 'shopping cart' which uses CourseMode.HONOR as
# course mode for creating course enrollments.
if CourseMode.is_white_label(course_id):
- course_mode = CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG
+ course_mode = CourseMode.HONOR
else:
course_mode = None
diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
index 4e0e46e768..e5b2b708d8 100644
--- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
@@ -1173,7 +1173,6 @@ class TestCourseSurveyReport(TestReportMixin, InstructorTaskCourseTestCase):
)
self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
- @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
def test_generate_course_survey_report(self):
"""
test to generate course survey report
diff --git a/lms/djangoapps/shoppingcart/admin.py b/lms/djangoapps/shoppingcart/admin.py
deleted file mode 100644
index e0f766a327..0000000000
--- a/lms/djangoapps/shoppingcart/admin.py
+++ /dev/null
@@ -1,183 +0,0 @@
-"""Django admin interface for the shopping cart models. """
-
-
-from django.contrib import admin
-
-from shoppingcart.models import (
- Coupon,
- CourseRegistrationCodeInvoiceItem,
- DonationConfiguration,
- Invoice,
- InvoiceTransaction,
- PaidCourseRegistrationAnnotation
-)
-
-
-class SoftDeleteCouponAdmin(admin.ModelAdmin):
- """
- Admin for the Coupon table.
- soft-delete on the coupons
- """
- fields = ('code', 'description', 'course_id', 'percentage_discount', 'created_by', 'created_at', 'is_active')
- raw_id_fields = ("created_by",)
- readonly_fields = ('created_at',)
- actions = ['really_delete_selected']
-
- def get_queryset(self, request):
- """
- Returns a QuerySet of all model instances that can be edited by the
- admin site - used by changelist_view.
- """
- qs = super(SoftDeleteCouponAdmin, self).get_queryset(request)
- return qs.filter(is_active=True)
-
- def get_actions(self, request):
- actions = super(SoftDeleteCouponAdmin, self).get_actions(request)
- if 'delete_selected' in actions:
- del actions['delete_selected']
- return actions
-
- def really_delete_selected(self, request, queryset):
- """override the default behavior of selected delete method"""
- for obj in queryset:
- obj.is_active = False
- obj.save()
-
- if queryset.count() == 1:
- message_bit = "1 coupon entry was"
- else:
- message_bit = u"%s coupon entries were" % queryset.count()
- self.message_user(request, u"%s successfully deleted." % message_bit)
-
- def delete_model(self, request, obj):
- """override the default behavior of single instance of model delete method"""
- obj.is_active = False
- obj.save()
-
- really_delete_selected.short_description = "Delete s selected entries"
-
-
-class CourseRegistrationCodeInvoiceItemInline(admin.StackedInline):
- """Admin for course registration code invoice items.
-
- Displayed inline within the invoice admin UI.
- """
- model = CourseRegistrationCodeInvoiceItem
- extra = 0
- can_delete = False
- readonly_fields = (
- 'qty',
- 'unit_price',
- 'currency',
- 'course_id',
- )
-
- def has_add_permission(self, request, obj=None):
- return False
-
-
-class InvoiceTransactionInline(admin.StackedInline):
- """Admin for invoice transactions.
-
- Displayed inline within the invoice admin UI.
- """
- model = InvoiceTransaction
- extra = 0
- readonly_fields = (
- 'created',
- 'modified',
- 'created_by',
- 'last_modified_by'
- )
-
-
-class InvoiceAdmin(admin.ModelAdmin):
- """Admin for invoices.
-
- This is intended for the internal finance team
- to be able to view and update invoice information,
- including payments and refunds.
-
- """
- date_hierarchy = 'created'
- can_delete = False
- readonly_fields = ('created', 'modified')
- search_fields = (
- 'internal_reference',
- 'customer_reference_number',
- 'company_name',
- )
- fieldsets = (
- (
- None, {
- 'fields': (
- 'internal_reference',
- 'customer_reference_number',
- 'created',
- 'modified',
- )
- }
- ),
- (
- 'Billing Information', {
- 'fields': (
- 'company_name',
- 'company_contact_name',
- 'company_contact_email',
- 'recipient_name',
- 'recipient_email',
- 'address_line_1',
- 'address_line_2',
- 'address_line_3',
- 'city',
- 'state',
- 'zip',
- 'country'
- )
- }
- )
- )
- readonly_fields = (
- 'internal_reference',
- 'customer_reference_number',
- 'created',
- 'modified',
- 'company_name',
- 'company_contact_name',
- 'company_contact_email',
- 'recipient_name',
- 'recipient_email',
- 'address_line_1',
- 'address_line_2',
- 'address_line_3',
- 'city',
- 'state',
- 'zip',
- 'country'
- )
- inlines = [
- CourseRegistrationCodeInvoiceItemInline,
- InvoiceTransactionInline
- ]
-
- def save_formset(self, request, form, formset, change):
- """Save the user who created and modified invoice transactions. """
- instances = formset.save(commit=False)
- for instance in instances:
- if isinstance(instance, InvoiceTransaction):
- if not hasattr(instance, 'created_by'):
- instance.created_by = request.user
- instance.last_modified_by = request.user
- instance.save()
-
- def has_add_permission(self, request):
- return False
-
- def has_delete_permission(self, request, obj=None):
- return False
-
-
-admin.site.register(PaidCourseRegistrationAnnotation)
-admin.site.register(Coupon, SoftDeleteCouponAdmin)
-admin.site.register(DonationConfiguration)
-admin.site.register(Invoice, InvoiceAdmin)
diff --git a/lms/djangoapps/shoppingcart/api.py b/lms/djangoapps/shoppingcart/api.py
deleted file mode 100644
index e13ac2c6c0..0000000000
--- a/lms/djangoapps/shoppingcart/api.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""
-API for for getting information about the user's shopping cart.
-"""
-
-
-from django.urls import reverse
-
-from shoppingcart.models import OrderItem
-from xmodule.modulestore.django import ModuleI18nService
-
-
-def order_history(user, **kwargs):
- """
- Returns the list of previously purchased orders for a user. Only the orders with
- PaidCourseRegistration and CourseRegCodeItem are returned.
- Params:
- course_org_filter: A list of the current Site's orgs.
- org_filter_out_set: A list of all other Sites' orgs.
- """
- course_org_filter = kwargs['course_org_filter'] if 'course_org_filter' in kwargs else None
- org_filter_out_set = kwargs['org_filter_out_set'] if 'org_filter_out_set' in kwargs else []
-
- order_history_list = []
- purchased_order_items = OrderItem.objects.filter(user=user, status='purchased').select_subclasses().order_by('-fulfilled_time')
- for order_item in purchased_order_items:
- # Avoid repeated entries for the same order id.
- if order_item.order.id not in [item['order_id'] for item in order_history_list]:
- order_item_course_id = getattr(order_item, 'course_id', None)
- if order_item_course_id:
- if (course_org_filter and order_item_course_id.org in course_org_filter) or \
- (course_org_filter is None and order_item_course_id.org not in org_filter_out_set):
- order_history_list.append({
- 'order_id': order_item.order.id,
- 'receipt_url': reverse('shoppingcart.views.show_receipt', kwargs={'ordernum': order_item.order.id}),
- 'order_date': ModuleI18nService().strftime(order_item.order.purchase_time, 'SHORT_DATE')
- })
- return order_history_list
diff --git a/lms/djangoapps/shoppingcart/context_processor.py b/lms/djangoapps/shoppingcart/context_processor.py
deleted file mode 100644
index 2b118be551..0000000000
--- a/lms/djangoapps/shoppingcart/context_processor.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""
-This is the shoppingcart context_processor module.
-Currently the only context_processor detects whether request.user has a cart that should be displayed in the
-navigation. We want to do this in the context_processor to
-1) keep database accesses out of templates (this led to a transaction bug with user email changes)
-2) because navigation.html is "called" by being included in other templates, there's no "views.py" to put this.
-"""
-
-from .models import CourseRegCodeItem, Order, PaidCourseRegistration
-from .utils import is_shopping_cart_enabled
-
-
-def user_has_cart_context_processor(request):
- """
- Checks if request has an authenticated user. If so, checks if request.user has a cart that should
- be displayed. Anonymous users don't.
- Adds `display_shopping_cart` to the context
- """
- def should_display_shopping_cart():
- """
- Returns a boolean if the user has an items in a cart whereby the shopping cart should be
- displayed to the logged in user
- """
- return (
- # user is logged in and
- request.user.is_authenticated and
- # do we have the feature turned on
- is_shopping_cart_enabled() and
- # does the user actually have a cart (optimized query to prevent creation of a cart when not needed)
- Order.does_user_have_cart(request.user) and
- # user's cart has PaidCourseRegistrations or CourseRegCodeItem
- Order.user_cart_has_items(
- request.user,
- [PaidCourseRegistration, CourseRegCodeItem]
- )
- )
-
- return {'should_display_shopping_cart_func': should_display_shopping_cart}
diff --git a/lms/djangoapps/shoppingcart/decorators.py b/lms/djangoapps/shoppingcart/decorators.py
deleted file mode 100644
index cf7c62d3af..0000000000
--- a/lms/djangoapps/shoppingcart/decorators.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""
-This file defines any decorators used by the shopping cart app
-"""
-
-
-from django.http import Http404
-
-from .utils import is_shopping_cart_enabled
-
-
-def enforce_shopping_cart_enabled(func):
- """
- Is a decorator that forces a wrapped method to be run in a runtime
- which has the ENABLE_SHOPPING_CART flag set
- """
- def func_wrapper(*args, **kwargs):
- """
- Wrapper function that does the enforcement that
- the shopping cart feature is enabled
- """
- if not is_shopping_cart_enabled():
- raise Http404
- return func(*args, **kwargs)
- return func_wrapper
diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py
deleted file mode 100644
index 1fbddab6f6..0000000000
--- a/lms/djangoapps/shoppingcart/exceptions.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-Exceptions for the shoppingcart app
-"""
-
-
-class PaymentException(Exception):
- pass
-
-
-class PurchasedCallbackException(PaymentException):
- pass
-
-
-class InvalidCartItem(PaymentException):
- pass
-
-
-class ItemAlreadyInCartException(InvalidCartItem):
- pass
-
-
-class AlreadyEnrolledInCourseException(InvalidCartItem):
- pass
-
-
-class CourseDoesNotExistException(InvalidCartItem):
- pass
-
-
-class CouponDoesNotExistException(InvalidCartItem):
- pass
-
-
-class MultipleCouponsNotAllowedException(InvalidCartItem):
- pass
-
-
-class RedemptionCodeError(Exception):
- """An error occurs while processing redemption codes. """
- pass
-
-
-class ReportException(Exception):
- pass
-
-
-class ReportTypeDoesNotExistException(ReportException):
- pass
-
-
-class InvalidStatusToRetire(Exception):
- pass
-
-
-class UnexpectedOrderItemStatus(Exception):
- pass
-
-
-class ItemNotFoundInCartException(Exception):
- pass
diff --git a/lms/djangoapps/shoppingcart/management/__init__.py b/lms/djangoapps/shoppingcart/management/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/shoppingcart/management/commands/__init__.py b/lms/djangoapps/shoppingcart/management/commands/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/shoppingcart/management/commands/retire_order.py b/lms/djangoapps/shoppingcart/management/commands/retire_order.py
deleted file mode 100644
index 3ff1f2cbe0..0000000000
--- a/lms/djangoapps/shoppingcart/management/commands/retire_order.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Script for retiring order that went through cybersource but weren't
-marked as "purchased" in the db
-"""
-
-
-from django.core.management.base import BaseCommand
-from six import text_type
-
-from shoppingcart.exceptions import InvalidStatusToRetire, UnexpectedOrderItemStatus
-from shoppingcart.models import Order
-
-
-class Command(BaseCommand):
- """
- Retire orders that went through cybersource but weren't updated
- appropriately in the db
- """
- help = """
- Retire orders that went through cybersource but weren't updated appropriately in the db.
- Takes a file of orders to be retired, one order per line
- """
-
- def add_arguments(self, parser):
- parser.add_argument('file_name')
-
- def handle(self, *args, **options):
- """Execute the command"""
-
- with open(options['file_name']) as orders_file:
- order_ids = [int(line.strip()) for line in orders_file.readlines()]
-
- orders = Order.objects.filter(id__in=order_ids)
-
- for order in orders:
- old_status = order.status
- try:
- order.retire()
- except (UnexpectedOrderItemStatus, InvalidStatusToRetire) as err:
- print(u"Did not retire order {order}: {message}".format(
- order=order.id, message=text_type(err)
- ))
- else:
- print(u"retired order {order_id} from status {old_status} to status {new_status}".format(
- order_id=order.id,
- old_status=old_status,
- new_status=order.status,
- ))
diff --git a/lms/djangoapps/shoppingcart/management/tests/__init__.py b/lms/djangoapps/shoppingcart/management/tests/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/shoppingcart/management/tests/test_retire_order.py b/lms/djangoapps/shoppingcart/management/tests/test_retire_order.py
deleted file mode 100644
index a77a9829c4..0000000000
--- a/lms/djangoapps/shoppingcart/management/tests/test_retire_order.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""Tests for the retire_order command"""
-
-
-from six import text_type
-from tempfile import NamedTemporaryFile
-
-from django.core.management import call_command
-
-from course_modes.models import CourseMode
-from shoppingcart.models import CertificateItem, Order
-from student.tests.factories import UserFactory
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory
-
-
-class TestRetireOrder(ModuleStoreTestCase):
- """Test the retire_order command"""
-
- def setUp(self):
- super(TestRetireOrder, self).setUp()
-
- course = CourseFactory.create()
- self.course_key = course.id
- CourseMode.objects.create(
- course_id=self.course_key,
- mode_slug=CourseMode.HONOR,
- mode_display_name=CourseMode.HONOR
- )
-
- # set up test carts
- self.cart, __ = self._create_cart()
-
- self.paying, __ = self._create_cart()
- self.paying.start_purchase()
-
- self.already_defunct_cart, __ = self._create_cart()
- self.already_defunct_cart.retire()
-
- self.purchased, self.purchased_item = self._create_cart()
- self.purchased.status = "purchased"
- self.purchased.save()
- self.purchased_item.status = "purchased"
- self.purchased.save()
-
- def test_retire_order(self):
- """Test the retire_order command"""
- nonexistent_id = max(order.id for order in Order.objects.all()) + 1
- order_ids = [
- self.cart.id,
- self.paying.id,
- self.already_defunct_cart.id,
- self.purchased.id,
- nonexistent_id
- ]
-
- self._create_tempfile_and_call_command(order_ids)
-
- self.assertEqual(
- Order.objects.get(id=self.cart.id).status, "defunct-cart"
- )
- self.assertEqual(
- Order.objects.get(id=self.paying.id).status, "defunct-paying"
- )
- self.assertEqual(
- Order.objects.get(id=self.already_defunct_cart.id).status,
- "defunct-cart"
- )
- self.assertEqual(
- Order.objects.get(id=self.purchased.id).status, "purchased"
- )
-
- def _create_tempfile_and_call_command(self, order_ids):
- """
- Takes a list of order_ids, writes them to a tempfile, and then runs the
- "retire_order" command on the tempfile
- """
- with NamedTemporaryFile() as temp:
- temp.write("\n".join(text_type(order_id) for order_id in order_ids).encode('utf-8'))
- temp.seek(0)
- call_command('retire_order', temp.name)
-
- def _create_cart(self):
- """Creates a cart and adds a CertificateItem to it"""
- cart = Order.get_cart_for_user(UserFactory.create())
- item = CertificateItem.add_to_order(
- cart, self.course_key, 10, 'honor', currency='usd'
- )
- return cart, item
diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py
deleted file mode 100644
index 026f659c93..0000000000
--- a/lms/djangoapps/shoppingcart/models.py
+++ /dev/null
@@ -1,2245 +0,0 @@
-# pylint: disable=arguments-differ
-""" Models for the shopping cart and assorted purchase types """
-
-
-import csv
-import json
-import logging
-import smtplib
-from collections import namedtuple
-from datetime import datetime, timedelta
-from decimal import Decimal
-
-import pytz
-import six
-from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors
-from config_models.models import ConfigurationModel
-from django.conf import settings
-from django.contrib.auth.models import User
-from django.core.exceptions import ObjectDoesNotExist
-from django.core.mail import send_mail
-from django.core.mail.message import EmailMessage
-from django.db import models, transaction
-from django.db.models import Count, F, Q, Sum
-from django.db.models.signals import post_delete, post_save
-from django.dispatch import receiver
-from django.urls import reverse
-from django.utils.encoding import python_2_unicode_compatible
-from django.utils.translation import ugettext as _
-from django.utils.translation import ugettext_lazy
-from model_utils.managers import InheritanceManager
-from model_utils.models import TimeStampedModel
-from opaque_keys.edx.django.models import CourseKeyField
-from six import text_type
-from six.moves import range
-
-from course_modes.models import CourseMode
-from lms.djangoapps.courseware.courses import get_course_by_id
-from edxmako.shortcuts import render_to_string
-from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
-from openedx.core.djangolib.markup import HTML, Text
-from student.models import CourseEnrollment, EnrollStatusChange
-from student.signals import UNENROLL_DONE
-from track import segment
-from util.query import use_read_replica_if_available
-from xmodule.modulestore.django import modulestore
-
-from .exceptions import (
- AlreadyEnrolledInCourseException,
- CourseDoesNotExistException,
- InvalidCartItem,
- InvalidStatusToRetire,
- ItemAlreadyInCartException,
- ItemNotFoundInCartException,
- MultipleCouponsNotAllowedException,
- PurchasedCallbackException,
- UnexpectedOrderItemStatus
-)
-
-log = logging.getLogger("shoppingcart")
-
-ORDER_STATUSES = (
- # The user is selecting what he/she wants to purchase.
- (u'cart', u'cart'),
-
- # The user has been sent to the external payment processor.
- # At this point, the order should NOT be modified.
- # If the user returns to the payment flow, he/she will start a new order.
- (u'paying', u'paying'),
-
- # The user has successfully purchased the items in the order.
- (u'purchased', u'purchased'),
-
- # The user's order has been refunded.
- (u'refunded', u'refunded'),
-
- # The user's order went through, but the order was erroneously left
- # in 'cart'.
- (u'defunct-cart', u'defunct-cart'),
-
- # The user's order went through, but the order was erroneously left
- # in 'paying'.
- (u'defunct-paying', u'defunct-paying'),
-)
-
-# maps order statuses to their defunct states
-ORDER_STATUS_MAP = {
- u'cart': u'defunct-cart',
- u'paying': u'defunct-paying',
-}
-
-# we need a tuple to represent the primary key of various OrderItem subclasses
-OrderItemSubclassPK = namedtuple('OrderItemSubclassPK', ['cls', 'pk'])
-
-
-class OrderTypes(object):
- """
- This class specify purchase OrderTypes.
- """
- PERSONAL = u'personal'
- BUSINESS = u'business'
-
- ORDER_TYPES = (
- (PERSONAL, u'personal'),
- (BUSINESS, u'business'),
- )
-
-
-class Order(models.Model):
- """
- This is the model for an order. Before purchase, an Order and its related OrderItems are used
- as the shopping cart.
- FOR ANY USER, THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart'.
-
- .. pii: Contains many PII fields in an app edx.org does not currently use. "other" data is payment information.
- .. pii_types: name, location, email_address, other
- .. pii_retirement: retained
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
- currency = models.CharField(default=u"usd", max_length=8) # lower case ISO currency codes
- status = models.CharField(max_length=32, default=u'cart', choices=ORDER_STATUSES)
- purchase_time = models.DateTimeField(null=True, blank=True)
- refunded_time = models.DateTimeField(null=True, blank=True)
- # Now we store data needed to generate a reasonable receipt
- # These fields only make sense after the purchase
- bill_to_first = models.CharField(max_length=64, blank=True)
- bill_to_last = models.CharField(max_length=64, blank=True)
- bill_to_street1 = models.CharField(max_length=128, blank=True)
- bill_to_street2 = models.CharField(max_length=128, blank=True)
- bill_to_city = models.CharField(max_length=64, blank=True)
- bill_to_state = models.CharField(max_length=8, blank=True)
- bill_to_postalcode = models.CharField(max_length=16, blank=True)
- bill_to_country = models.CharField(max_length=64, blank=True)
- bill_to_ccnum = models.CharField(max_length=8, blank=True) # last 4 digits
- bill_to_cardtype = models.CharField(max_length=32, blank=True)
- # a JSON dump of the CC processor response, for completeness
- processor_reply_dump = models.TextField(blank=True)
-
- # bulk purchase registration code workflow billing details
- company_name = models.CharField(max_length=255, null=True, blank=True)
- company_contact_name = models.CharField(max_length=255, null=True, blank=True)
- company_contact_email = models.CharField(max_length=255, null=True, blank=True)
- recipient_name = models.CharField(max_length=255, null=True, blank=True)
- recipient_email = models.CharField(max_length=255, null=True, blank=True)
- customer_reference_number = models.CharField(max_length=63, null=True, blank=True)
- order_type = models.CharField(max_length=32, default=u'personal', choices=OrderTypes.ORDER_TYPES)
-
- @classmethod
- def get_cart_for_user(cls, user):
- """
- Always use this to preserve the property that at most 1 order per user has status = 'cart'
- """
- # find the newest element in the db
- try:
- cart_order = cls.objects.filter(user=user, status='cart').order_by('-id')[:1].get()
- except ObjectDoesNotExist:
- # if nothing exists in the database, create a new cart
- cart_order, _created = cls.objects.get_or_create(user=user, status='cart')
- return cart_order
-
- @classmethod
- def does_user_have_cart(cls, user):
- """
- Returns a boolean whether a shopping cart (Order) exists for the specified user
- """
- return cls.objects.filter(user=user, status='cart').exists()
-
- @classmethod
- def user_cart_has_items(cls, user, item_types=None):
- """
- Returns true if the user (anonymous user ok) has
- a cart with items in it. (Which means it should be displayed.
- If a item_type is passed in, then we check to see if the cart has at least one of
- those types of OrderItems
- """
- if not user.is_authenticated:
- return False
- cart = cls.get_cart_for_user(user)
-
- if not item_types:
- # check to see if the cart has at least some item in it
- return cart.has_items()
- else:
- # if the caller is explicitly asking to check for particular types
- for item_type in item_types:
- if cart.has_items(item_type):
- return True
-
- return False
-
- @classmethod
- def remove_cart_item_from_order(cls, item, user):
- """
- Removes the item from the cart if the item.order.status == 'cart'.
- Also removes any code redemption associated with the order_item
- """
- if item.order.status == 'cart':
- log.info(u"order item %s removed for user %s", str(item.id), user)
- item.delete()
- # remove any redemption entry associated with the item
- CouponRedemption.remove_code_redemption_from_item(item, user)
-
- @property
- def total_cost(self):
- """
- Return the total cost of the cart. If the order has been purchased, returns total of
- all purchased and not refunded items.
- """
- return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status))
-
- def has_items(self, item_type=None):
- """
- Does the cart have any items in it?
- If an item_type is passed in then we check to see if there are any items of that class type
- """
- if not item_type:
- return self.orderitem_set.exists()
- else:
- items = self.orderitem_set.all().select_subclasses()
- for item in items:
- if isinstance(item, item_type):
- return True
- return False
-
- def reset_cart_items_prices(self):
- """
- Reset the items price state in the user cart
- """
- for item in self.orderitem_set.all():
- if item.is_discounted:
- item.unit_cost = item.list_price
- item.save()
-
- def clear(self):
- """
- Clear out all the items in the cart
- """
- self.orderitem_set.all().delete()
-
- @transaction.atomic
- def start_purchase(self):
- """
- Start the purchase process. This will set the order status to "paying",
- at which point it should no longer be modified.
-
- Future calls to `Order.get_cart_for_user()` will filter out orders with
- status "paying", effectively creating a new (empty) cart.
- """
- if self.status == 'cart':
- self.status = 'paying'
- self.save()
-
- for item in OrderItem.objects.filter(order=self).select_subclasses():
- item.start_purchase()
-
- def update_order_type(self):
- """
- updating order type. This method wil inspect the quantity associated with the OrderItem.
- In the application, it is implied that when qty > 1, then the user is to purchase
- 'RegistrationCodes' which are randomly generated strings that users can distribute to
- others in order for them to enroll in paywalled courses.
-
- The UI/UX may change in the future to make the switching between PaidCourseRegistration
- and CourseRegCodeItems a more explicit UI gesture from the purchaser
- """
- cart_items = self.orderitem_set.all()
- is_order_type_business = False
- for cart_item in cart_items:
- if cart_item.qty > 1:
- is_order_type_business = True
-
- items_to_delete = []
- old_to_new_id_map = []
- if is_order_type_business:
- for cart_item in cart_items:
- if hasattr(cart_item, 'paidcourseregistration'):
- course_reg_code_item = CourseRegCodeItem.add_to_order(
- self, cart_item.paidcourseregistration.course_id, cart_item.qty,
- )
- # update the discounted prices if coupon redemption applied
- course_reg_code_item.list_price = cart_item.list_price
- course_reg_code_item.unit_cost = cart_item.unit_cost
- course_reg_code_item.save()
- items_to_delete.append(cart_item)
- old_to_new_id_map.append({"oldId": cart_item.id, "newId": course_reg_code_item.id})
- else:
- for cart_item in cart_items:
- if hasattr(cart_item, 'courseregcodeitem'):
- paid_course_registration = PaidCourseRegistration.add_to_order(
- self, cart_item.courseregcodeitem.course_id,
- )
- # update the discounted prices if coupon redemption applied
- paid_course_registration.list_price = cart_item.list_price
- paid_course_registration.unit_cost = cart_item.unit_cost
- paid_course_registration.save()
- items_to_delete.append(cart_item)
- old_to_new_id_map.append({"oldId": cart_item.id, "newId": paid_course_registration.id})
-
- for item in items_to_delete:
- item.delete()
-
- self.order_type = OrderTypes.BUSINESS if is_order_type_business else OrderTypes.PERSONAL
- self.save()
- return old_to_new_id_map
-
- def generate_registration_codes_csv(self, orderitems, site_name):
- """
- this function generates the csv file
- """
- course_names = []
- csv_file = six.StringIO()
- csv_writer = csv.writer(csv_file)
- csv_writer.writerow(['Course Name', 'Registration Code', 'URL'])
- for item in orderitems:
- course_id = item.course_id
- course = get_course_by_id(item.course_id, depth=0)
- registration_codes = CourseRegistrationCode.objects.filter(course_id=course_id, order=self)
- course_names.append(course.display_name)
- for registration_code in registration_codes:
- redemption_url = reverse('register_code_redemption', args=[registration_code.code])
- url = '{base_url}{redemption_url}'.format(base_url=site_name, redemption_url=redemption_url)
- csv_writer.writerow([six.text_type(course.display_name).encode("utf-8"), registration_code.code, url])
-
- return csv_file, course_names
-
- def send_confirmation_emails(self, orderitems, is_order_type_business, csv_file, site_name, course_names):
- """
- send confirmation e-mail
- """
- recipient_list = [(self.user.username, self.user.email, 'user')]
- if self.company_contact_email:
- recipient_list.append((self.company_contact_name, self.company_contact_email, 'company_contact'))
- joined_course_names = ""
- if self.recipient_email:
- recipient_list.append((self.recipient_name, self.recipient_email, 'email_recipient'))
- joined_course_names = " " + ", ".join(course_names)
-
- if not is_order_type_business:
- subject = _("Order Payment Confirmation")
- else:
- subject = _(u'Confirmation and Registration Codes for the following courses: {course_name_list}').format(
- course_name_list=joined_course_names
- )
-
- dashboard_url = '{base_url}{dashboard}'.format(
- base_url=site_name,
- dashboard=reverse('dashboard')
- )
- try:
- from_address = configuration_helpers.get_value(
- 'email_from_address',
- settings.PAYMENT_SUPPORT_EMAIL
- )
- # Send a unique email for each recipient. Don't put all email addresses in a single email.
- for recipient in recipient_list:
- message = render_to_string(
- 'emails/business_order_confirmation_email.txt' if is_order_type_business else 'emails/order_confirmation_email.txt',
- {
- 'order': self,
- 'recipient_name': recipient[0],
- 'recipient_type': recipient[2],
- 'site_name': site_name,
- 'order_items': orderitems,
- 'course_names': ", ".join(course_names),
- 'dashboard_url': dashboard_url,
- 'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1],
- 'order_placed_by': u'{username} ({email})'.format(
- username=self.user.username, email=self.user.email
- ),
- 'has_billing_info': settings.FEATURES['STORE_BILLING_INFO'],
- 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
- 'payment_support_email': configuration_helpers.get_value(
- 'payment_support_email', settings.PAYMENT_SUPPORT_EMAIL,
- ),
- 'payment_email_signature': configuration_helpers.get_value('payment_email_signature'),
- }
- )
- email = EmailMessage(
- subject=subject,
- body=message,
- from_email=from_address,
- to=[recipient[1]]
- )
-
- # Only the business order is HTML formatted. A single seat order confirmation is plain text.
- if is_order_type_business:
- email.content_subtype = "html"
-
- if csv_file:
- email.attach(u'RegistrationCodesRedemptionUrls.csv', csv_file.getvalue(), 'text/csv')
- email.send()
- except (smtplib.SMTPException, BotoServerError): # sadly need to handle diff. mail backends individually
- log.error(u'Failed sending confirmation e-mail for order %d', self.id)
-
- def purchase(self, first='', last='', street1='', street2='', city='', state='', postalcode='',
- country='', ccnum='', cardtype='', processor_reply_dump=''):
- """
- Call to mark this order as purchased. Iterates through its OrderItems and calls
- their purchased_callback
-
- `first` - first name of person billed (e.g. John)
- `last` - last name of person billed (e.g. Smith)
- `street1` - first line of a street address of the billing address (e.g. 11 Cambridge Center)
- `street2` - second line of a street address of the billing address (e.g. Suite 101)
- `city` - city of the billing address (e.g. Cambridge)
- `state` - code of the state, province, or territory of the billing address (e.g. MA)
- `postalcode` - postal code of the billing address (e.g. 02142)
- `country` - country code of the billing address (e.g. US)
- `ccnum` - last 4 digits of the credit card number of the credit card billed (e.g. 1111)
- `cardtype` - 3-digit code representing the card type used (e.g. 001)
- `processor_reply_dump` - all the parameters returned by the processor
-
- """
- if self.status == 'purchased':
- log.error(
- u"`purchase` method called on order {}, but order is already purchased.".format(self.id)
- )
- return
- self.status = 'purchased'
- self.purchase_time = datetime.now(pytz.utc)
- self.bill_to_first = first
- self.bill_to_last = last
- self.bill_to_city = city
- self.bill_to_state = state
- self.bill_to_country = country
- self.bill_to_postalcode = postalcode
- if settings.FEATURES['STORE_BILLING_INFO']:
- self.bill_to_street1 = street1
- self.bill_to_street2 = street2
- self.bill_to_ccnum = ccnum
- self.bill_to_cardtype = cardtype
- self.processor_reply_dump = processor_reply_dump
-
- # save these changes on the order, then we can tell when we are in an
- # inconsistent state
- self.save()
- # this should return all of the objects with the correct types of the
- # subclasses
- orderitems = OrderItem.objects.filter(order=self).select_subclasses()
- site_name = configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME)
-
- if self.order_type == OrderTypes.BUSINESS:
- self.update_order_type()
-
- for item in orderitems:
- item.purchase_item()
-
- csv_file = None
- course_names = []
- if self.order_type == OrderTypes.BUSINESS:
- #
- # Generate the CSV file that contains all of the RegistrationCodes that have already been
- # generated when the purchase has transacted
- #
- csv_file, course_names = self.generate_registration_codes_csv(orderitems, site_name)
-
- try:
- self.send_confirmation_emails(
- orderitems, self.order_type == OrderTypes.BUSINESS,
- csv_file, site_name, course_names
- )
- except Exception: # pylint: disable=broad-except
- # Catch all exceptions here, since the Django view implicitly
- # wraps this in a transaction. If the order completes successfully,
- # we don't want to roll back just because we couldn't send
- # the confirmation email.
- log.exception('Error occurred while sending payment confirmation email')
-
- self._emit_order_event('Completed Order', orderitems)
-
- def refund(self):
- """
- Refund the given order. As of right now, this just marks the order as refunded.
- """
- self.status = 'refunded'
- self.save()
- orderitems = OrderItem.objects.filter(order=self).select_subclasses()
- self._emit_order_event('Refunded Order', orderitems)
-
- def _emit_order_event(self, event_name, orderitems):
- """
- Emit an analytics event with the given name for this Order. Will iterate over all associated
- OrderItems and add them as products in the event as well.
-
- """
- try:
- segment.track(self.user.id, event_name, {
- 'orderId': self.id,
- 'total': str(self.total_cost),
- # For Rockerbox integration, we need a field named revenue since they cannot parse a field named total.
- # TODO: DE-1188: Remove / move Rockerbox integration code.
- 'revenue': str(self.total_cost),
- 'currency': self.currency,
- 'products': [item.analytics_data() for item in orderitems]
- })
-
- except Exception: # pylint: disable=broad-except
- # Capturing all exceptions thrown while tracking analytics events. We do not want
- # an operation to fail because of an analytics event, so we will capture these
- # errors in the logs.
- log.exception(
- u'Unable to emit {event} event for user {user} and order {order}'.format(
- event=event_name, user=self.user.id, order=self.id)
- )
-
- def add_billing_details(self, company_name='', company_contact_name='', company_contact_email='', recipient_name='',
- recipient_email='', customer_reference_number=''):
- """
- This function is called after the user selects a purchase type of "Business" and
- is asked to enter the optional billing details. The billing details are updated
- for that order.
-
- company_name - Name of purchasing organization
- company_contact_name - Name of the key contact at the company the sale was made to
- company_contact_email - Email of the key contact at the company the sale was made to
- recipient_name - Name of the company should the invoice be sent to
- recipient_email - Email of the company should the invoice be sent to
- customer_reference_number - purchase order number of the organization associated with this Order
- """
-
- self.company_name = company_name
- self.company_contact_name = company_contact_name
- self.company_contact_email = company_contact_email
- self.recipient_name = recipient_name
- self.recipient_email = recipient_email
- self.customer_reference_number = customer_reference_number
-
- self.save()
-
- def generate_receipt_instructions(self):
- """
- Call to generate specific instructions for each item in the order. This gets displayed on the receipt
- page, typically. Instructions are something like "visit your dashboard to see your new courses".
- This will return two things in a pair. The first will be a dict with keys=OrderItemSubclassPK corresponding
- to an OrderItem and values=a set of html instructions they generate. The second will be a set of de-duped
- html instructions
- """
- instruction_set = set([]) # heh. not ia32 or alpha or sparc
- instruction_dict = {}
- order_items = OrderItem.objects.filter(order=self).select_subclasses()
- for item in order_items:
- item_pk_with_subclass, set_of_html = item.generate_receipt_instructions()
- instruction_dict[item_pk_with_subclass] = set_of_html
- instruction_set.update(set_of_html)
- return instruction_dict, instruction_set
-
- def retire(self):
- """
- Method to "retire" orders that have gone through to the payment service
- but have (erroneously) not had their statuses updated.
- This method only works on orders that satisfy the following conditions:
- 1) the order status is either "cart" or "paying" (otherwise we raise
- an InvalidStatusToRetire error)
- 2) the order's order item's statuses match the order's status (otherwise
- we throw an UnexpectedOrderItemStatus error)
- """
- # if an order is already retired, no-op:
- if self.status in list(ORDER_STATUS_MAP.values()):
- return
-
- if self.status not in list(ORDER_STATUS_MAP.keys()):
- raise InvalidStatusToRetire(
- u"order status {order_status} is not 'paying' or 'cart'".format(
- order_status=self.status
- )
- )
-
- for item in self.orderitem_set.all():
- if item.status != self.status:
- raise UnexpectedOrderItemStatus(
- "order_item status is different from order status"
- )
-
- self.status = ORDER_STATUS_MAP[self.status]
- self.save()
-
- for item in self.orderitem_set.all():
- item.retire()
-
- def find_item_by_course_id(self, course_id):
- """
- course_id: Course id of the item to find
- Returns OrderItem from the Order given a course_id
- Raises exception ItemNotFoundException when the item
- having the given course_id is not present in the cart
- """
- cart_items = OrderItem.objects.filter(order=self).select_subclasses()
- found_items = []
- for item in cart_items:
- if getattr(item, 'course_id', None):
- if item.course_id == course_id:
- found_items.append(item)
- if not found_items:
- raise ItemNotFoundInCartException
- return found_items
-
-
-class OrderItem(TimeStampedModel):
- """
- This is the basic interface for order items.
- Order items are line items that fill up the shopping carts and orders.
-
- Each implementation of OrderItem should provide its own purchased_callback as
- a method.
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
- base_manager_name = 'objects'
-
- objects = InheritanceManager()
- order = models.ForeignKey(Order, db_index=True, on_delete=models.CASCADE)
- # this is denormalized, but convenient for SQL queries for reports, etc. user should always be = order.user
- user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
- # this is denormalized, but convenient for SQL queries for reports, etc. status should always be = order.status
- status = models.CharField(max_length=32, default=u'cart', choices=ORDER_STATUSES, db_index=True)
- qty = models.IntegerField(default=1)
- unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30)
- list_price = models.DecimalField(decimal_places=2, max_digits=30, null=True)
- line_desc = models.CharField(default=u"Misc. Item", max_length=1024)
- currency = models.CharField(default=u"usd", max_length=8) # lower case ISO currency codes
- fulfilled_time = models.DateTimeField(null=True, db_index=True)
- refund_requested_time = models.DateTimeField(null=True, db_index=True)
- service_fee = models.DecimalField(default=0.0, decimal_places=2, max_digits=30)
- # general purpose field, not user-visible. Used for reporting
- report_comments = models.TextField(default=u"")
-
- @property
- def line_cost(self):
- """ Return the total cost of this OrderItem """
- return self.qty * self.unit_cost
-
- @line_cost.setter
- def line_cost(self, value):
- """
- Django requires there be a setter for this, but it is not
- necessary for the way we currently use it. Raising errors
- here will cause a lot of issues and these should not be
- mutable after construction, so for now we just eat this.
- """
- pass
-
- @classmethod
- def add_to_order(cls, order, *args, **kwargs):
- """
- A suggested convenience function for subclasses.
-
- NOTE: This does not add anything to the cart. That is left up to the
- subclasses to implement for themselves
- """
- # this is a validation step to verify that the currency of the item we
- # are adding is the same as the currency of the order we are adding it
- # to
- currency = kwargs.get('currency', 'usd')
- if order.currency != currency and order.orderitem_set.exists():
- raise InvalidCartItem(_("Trying to add a different currency into the cart"))
-
- @transaction.atomic
- def purchase_item(self):
- """
- This is basically a wrapper around purchased_callback that handles
- modifying the OrderItem itself
- """
- self.purchased_callback()
- self.status = 'purchased'
- self.fulfilled_time = datetime.now(pytz.utc)
- self.save()
-
- def start_purchase(self):
- """
- Start the purchase process. This will set the order item status to "paying",
- at which point it should no longer be modified.
- """
- self.status = 'paying'
- self.save()
-
- def purchased_callback(self):
- """
- This is called on each inventory item in the shopping cart when the
- purchase goes through.
- """
- raise NotImplementedError
-
- def generate_receipt_instructions(self):
- """
- This is called on each item in a purchased order to generate receipt instructions.
- This should return a list of `ReceiptInstruction`s in HTML string
- Default implementation is to return an empty set
- """
- return self.pk_with_subclass, set([])
-
- @property
- def pk_with_subclass(self):
- """
- Returns a named tuple that annotates the pk of this instance with its class, to fully represent
- a pk of a subclass (inclusive) of OrderItem
- """
- return OrderItemSubclassPK(type(self), self.pk)
-
- @property
- def is_discounted(self):
- """
- Returns True if the item a discount coupon has been applied to the OrderItem and False otherwise.
- Earlier, the OrderItems were stored with an empty list_price if a discount had not been applied.
- Now we consider the item to be non discounted if list_price is None or list_price == unit_cost. In
- these lines, an item is discounted if it's non-None and list_price and unit_cost mismatch.
- This should work with both new and old records.
- """
- return self.list_price and self.list_price != self.unit_cost
-
- def get_list_price(self):
- """
- Returns the unit_cost if no discount has been applied, or the list_price if it is defined.
- """
- return self.list_price if self.list_price else self.unit_cost
-
- @property
- def single_item_receipt_template(self):
- """
- The template that should be used when there's only one item in the order
- """
- return 'shoppingcart/receipt.html'
-
- @property
- def single_item_receipt_context(self):
- """
- Extra variables needed to render the template specified in
- `single_item_receipt_template`
- """
- return {}
-
- def additional_instruction_text(self, **kwargs): # pylint: disable=unused-argument
- """
- Individual instructions for this order item.
-
- Currently, only used for emails.
- """
- return ''
-
- @property
- def pdf_receipt_display_name(self):
- """
- How to display this item on a PDF printed receipt file.
- This can be overridden by the subclasses of OrderItem
- """
- course_key = getattr(self, 'course_id', None)
- if course_key:
- course = get_course_by_id(course_key, depth=0)
- return course.display_name
- else:
- raise Exception(
- "Not Implemented. OrderItems that are not Course specific should have"
- " a overridden pdf_receipt_display_name property"
- )
-
- def analytics_data(self):
- """Simple function used to construct analytics data for the OrderItem.
-
- The default implementation returns defaults for most attributes. When no name or
- category is specified by the implementation, the string 'N/A' is placed for the
- name and category. This should be handled appropriately by all implementations.
-
- Returns
- A dictionary containing analytics data for this OrderItem.
-
- """
- return {
- 'id': self.id,
- 'sku': type(self).__name__,
- 'name': 'N/A',
- 'price': str(self.unit_cost),
- 'quantity': self.qty,
- 'category': 'N/A',
- }
-
- def retire(self):
- """
- Called by the `retire` method defined in the `Order` class. Retires
- an order item if its (and its order's) status was erroneously not
- updated to "purchased" after the order was processed.
- """
- self.status = ORDER_STATUS_MAP[self.status]
- self.save()
-
-
-@python_2_unicode_compatible
-class Invoice(TimeStampedModel):
- """
- This table capture all the information needed to support "invoicing"
- which is when a user wants to purchase Registration Codes,
- but will not do so via a Credit Card transaction.
-
- .. pii: Contains many PII fields in an app edx.org does not currently use
- .. pii_types: name, location, email_address
- .. pii_retirement: retained
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- company_name = models.CharField(max_length=255, db_index=True)
- company_contact_name = models.CharField(max_length=255)
- company_contact_email = models.CharField(max_length=255)
- recipient_name = models.CharField(max_length=255)
- recipient_email = models.CharField(max_length=255)
- address_line_1 = models.CharField(max_length=255)
- address_line_2 = models.CharField(max_length=255, null=True, blank=True)
- address_line_3 = models.CharField(max_length=255, null=True, blank=True)
- city = models.CharField(max_length=255, null=True)
- state = models.CharField(max_length=255, null=True)
- zip = models.CharField(max_length=15, null=True)
- country = models.CharField(max_length=64, null=True)
-
- # This field has been deprecated.
- # The total amount can now be calculated as the sum
- # of each invoice item associated with the invoice.
- # For backwards compatibility, this field is maintained
- # and written to during invoice creation.
- total_amount = models.FloatField()
-
- # This field has been deprecated in order to support
- # invoices for items that are not course-related.
- # Although this field is still maintained for backwards
- # compatibility, you should use CourseRegistrationCodeInvoiceItem
- # to look up the course ID for purchased redeem codes.
- course_id = CourseKeyField(max_length=255, db_index=True)
-
- internal_reference = models.CharField(
- max_length=255,
- null=True,
- blank=True,
- help_text=ugettext_lazy("Internal reference code for this invoice.")
- )
- customer_reference_number = models.CharField(
- max_length=63,
- null=True,
- blank=True,
- help_text=ugettext_lazy("Customer's reference code for this invoice.")
- )
- is_valid = models.BooleanField(default=True)
-
- @classmethod
- def get_invoice_total_amount_for_course(cls, course_key):
- """
- returns the invoice total amount generated by course.
- """
- result = cls.objects.filter(course_id=course_key, is_valid=True).aggregate(total=Sum('total_amount'))
-
- total = result.get('total', 0)
- return total if total else 0
-
- def snapshot(self):
- """Create a snapshot of the invoice.
-
- A snapshot is a JSON-serializable representation
- of the invoice's state, including its line items
- and associated transactions (payments/refunds).
-
- This is useful for saving the history of changes
- to the invoice.
-
- Returns:
- dict
-
- """
- return {
- 'internal_reference': self.internal_reference,
- 'customer_reference': self.customer_reference_number,
- 'is_valid': self.is_valid,
- 'contact_info': {
- 'company_name': self.company_name,
- 'company_contact_name': self.company_contact_name,
- 'company_contact_email': self.company_contact_email,
- 'recipient_name': self.recipient_name,
- 'recipient_email': self.recipient_email,
- 'address_line_1': self.address_line_1,
- 'address_line_2': self.address_line_2,
- 'address_line_3': self.address_line_3,
- 'city': self.city,
- 'state': self.state,
- 'zip': self.zip,
- 'country': self.country,
- },
- 'items': [
- item.snapshot()
- for item in InvoiceItem.objects.filter(invoice=self).select_subclasses()
- ],
- 'transactions': [
- trans.snapshot()
- for trans in InvoiceTransaction.objects.filter(invoice=self)
- ],
- }
-
- def __str__(self):
- label = (
- six.text_type(self.internal_reference)
- if self.internal_reference
- else u"No label"
- )
-
- created = (
- self.created.strftime("%Y-%m-%d")
- if self.created
- else u"No date"
- )
-
- return u"{label} ({date_created})".format(
- label=label, date_created=created
- )
-
-
-INVOICE_TRANSACTION_STATUSES = (
- # A payment/refund is in process, but money has not yet been transferred
- (u'started', u'started'),
-
- # A payment/refund has completed successfully
- # This should be set ONLY once money has been successfully exchanged.
- (u'completed', u'completed'),
-
- # A payment/refund was promised, but was cancelled before
- # money had been transferred. An example would be
- # cancelling a refund check before the recipient has
- # a chance to deposit it.
- (u'cancelled', u'cancelled')
-)
-
-
-class InvoiceTransaction(TimeStampedModel):
- """Record payment and refund information for invoices.
-
- There are two expected use cases:
-
- 1) We send an invoice to someone, and they send us a check.
- We then manually create an invoice transaction to represent
- the payment.
-
- 2) We send an invoice to someone, and they pay us. Later, we
- need to issue a refund for the payment. We manually
- create a transaction with a negative amount to represent
- the refund.
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE)
- amount = models.DecimalField(
- default=0.0, decimal_places=2, max_digits=30,
- help_text=ugettext_lazy(
- "The amount of the transaction. Use positive amounts for payments"
- " and negative amounts for refunds."
- )
- )
- currency = models.CharField(
- default=u"usd",
- max_length=8,
- help_text=ugettext_lazy("Lower-case ISO currency codes")
- )
- comments = models.TextField(
- null=True,
- blank=True,
- help_text=ugettext_lazy("Optional: provide additional information for this transaction")
- )
- status = models.CharField(
- max_length=32,
- default=u'started',
- choices=INVOICE_TRANSACTION_STATUSES,
- help_text=ugettext_lazy(
- "The status of the payment or refund. "
- "'started' means that payment is expected, but money has not yet been transferred. "
- "'completed' means that the payment or refund was received. "
- "'cancelled' means that payment or refund was expected, but was cancelled before money was transferred. "
- )
- )
- created_by = models.ForeignKey(User, on_delete=models.CASCADE)
- last_modified_by = models.ForeignKey(User, related_name='last_modified_by_user', on_delete=models.CASCADE)
-
- @classmethod
- def get_invoice_transaction(cls, invoice_id):
- """
- if found Returns the Invoice Transaction object for the given invoice_id
- else returns None
- """
- try:
- return cls.objects.get(Q(invoice_id=invoice_id), Q(status='completed') | Q(status='refunded'))
- except InvoiceTransaction.DoesNotExist:
- return None
-
- @classmethod
- def get_total_amount_of_paid_course_invoices(cls, course_key):
- """
- returns the total amount of the paid invoices.
- """
- result = cls.objects.filter(amount__gt=0, invoice__course_id=course_key, status='completed').aggregate(
- total=Sum(
- 'amount',
- output_field=models.DecimalField(decimal_places=2, max_digits=30)
- )
- )
-
- total = result.get('total', 0)
- return total if total else 0
-
- def snapshot(self):
- """Create a snapshot of the invoice transaction.
-
- The returned dictionary is JSON-serializable.
-
- Returns:
- dict
-
- """
- return {
- 'amount': six.text_type(self.amount),
- 'currency': self.currency,
- 'comments': self.comments,
- 'status': self.status,
- 'created_by': self.created_by.username,
- 'last_modified_by': self.last_modified_by.username
- }
-
-
-class InvoiceItem(TimeStampedModel):
- """
- This is the basic interface for invoice items.
-
- Each invoice item represents a "line" in the invoice.
- For example, in an invoice for course registration codes,
- there might be an invoice item representing 10 registration
- codes for the DemoX course.
-
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
- base_manager_name = 'objects'
-
- objects = InheritanceManager()
- invoice = models.ForeignKey(Invoice, db_index=True, on_delete=models.CASCADE)
- qty = models.IntegerField(
- default=1,
- help_text=ugettext_lazy("The number of items sold.")
- )
- unit_price = models.DecimalField(
- default=0.0,
- decimal_places=2,
- max_digits=30,
- help_text=ugettext_lazy("The price per item sold, including discounts.")
- )
- currency = models.CharField(
- default=u"usd",
- max_length=8,
- help_text=ugettext_lazy("Lower-case ISO currency codes")
- )
-
- def snapshot(self):
- """Create a snapshot of the invoice item.
-
- The returned dictionary is JSON-serializable.
-
- Returns:
- dict
-
- """
- return {
- 'qty': self.qty,
- 'unit_price': six.text_type(self.unit_price),
- 'currency': self.currency
- }
-
-
-class CourseRegistrationCodeInvoiceItem(InvoiceItem):
- """
- This is an invoice item that represents a payment for
- a course registration.
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- course_id = CourseKeyField(max_length=128, db_index=True)
-
- def snapshot(self):
- """Create a snapshot of the invoice item.
-
- This is the same as a snapshot for other invoice items,
- with the addition of a `course_id` field.
-
- Returns:
- dict
-
- """
- snapshot = super(CourseRegistrationCodeInvoiceItem, self).snapshot()
- snapshot['course_id'] = six.text_type(self.course_id)
- return snapshot
-
-
-class InvoiceHistory(models.Model):
- """History of changes to invoices.
-
- This table stores snapshots of invoice state,
- including the associated line items and transactions
- (payments/refunds).
-
- Entries in the table are created, but never deleted
- or modified.
-
- We use Django signals to save history entries on change
- events. These signals are fired within a database
- transaction, so the history record is created only
- if the invoice change is successfully persisted.
-
- .. no_pii:
- """
- timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
- invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE)
-
- # JSON-serialized representation of the current state
- # of the invoice, including its line items and
- # transactions (payments/refunds).
- snapshot = models.TextField(blank=True)
-
- @classmethod
- def save_invoice_snapshot(cls, invoice):
- """Save a snapshot of the invoice's current state.
-
- Arguments:
- invoice (Invoice): The invoice to save.
-
- """
- cls.objects.create(
- invoice=invoice,
- snapshot=json.dumps(invoice.snapshot())
- )
-
- @staticmethod
- def snapshot_receiver(sender, instance, **kwargs): # pylint: disable=unused-argument
- """Signal receiver that saves a snapshot of an invoice.
-
- Arguments:
- sender: Not used, but required by Django signals.
- instance (Invoice, InvoiceItem, or InvoiceTransaction)
-
- """
- if isinstance(instance, Invoice):
- InvoiceHistory.save_invoice_snapshot(instance)
- elif hasattr(instance, 'invoice'):
- InvoiceHistory.save_invoice_snapshot(instance.invoice)
-
- class Meta(object):
- get_latest_by = "timestamp"
- app_label = "shoppingcart"
-
-
-# Hook up Django signals to record changes in the history table.
-# We record any change to an invoice, invoice item, or transaction.
-# We also record any deletion of a transaction, since users can delete
-# transactions via Django admin.
-# Note that we need to include *each* InvoiceItem subclass
-# here, since Django signals do not fire automatically for subclasses
-# of the "sender" class.
-post_save.connect(InvoiceHistory.snapshot_receiver, sender=Invoice)
-post_save.connect(InvoiceHistory.snapshot_receiver, sender=InvoiceItem)
-post_save.connect(InvoiceHistory.snapshot_receiver, sender=CourseRegistrationCodeInvoiceItem)
-post_save.connect(InvoiceHistory.snapshot_receiver, sender=InvoiceTransaction)
-post_delete.connect(InvoiceHistory.snapshot_receiver, sender=InvoiceTransaction)
-
-
-class CourseRegistrationCode(models.Model):
- """
- This table contains registration codes
- With registration code, a user can register for a course for free
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- code = models.CharField(max_length=32, db_index=True, unique=True)
- course_id = CourseKeyField(max_length=255, db_index=True)
- created_by = models.ForeignKey(User, related_name='created_by_user', on_delete=models.CASCADE)
- created_at = models.DateTimeField(auto_now_add=True)
- order = models.ForeignKey(Order, db_index=True, null=True, related_name="purchase_order", on_delete=models.CASCADE)
- mode_slug = models.CharField(max_length=100, null=True)
- is_valid = models.BooleanField(default=True)
-
- # For backwards compatibility, we maintain the FK to "invoice"
- # In the future, we will remove this in favor of the FK
- # to "invoice_item" (which can be used to look up the invoice).
- invoice = models.ForeignKey(Invoice, null=True, on_delete=models.CASCADE)
- invoice_item = models.ForeignKey(CourseRegistrationCodeInvoiceItem, null=True, on_delete=models.CASCADE)
-
- @classmethod
- def order_generated_registration_codes(cls, course_id):
- """
- Returns the registration codes that were generated
- via bulk purchase scenario.
- """
- return cls.objects.filter(order__isnull=False, course_id=course_id)
-
- @classmethod
- def invoice_generated_registration_codes(cls, course_id):
- """
- Returns the registration codes that were generated
- via invoice.
- """
- return cls.objects.filter(invoice__isnull=False, course_id=course_id)
-
-
-class RegistrationCodeRedemption(models.Model):
- """
- This model contains the registration-code redemption info
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- order = models.ForeignKey(Order, db_index=True, null=True, on_delete=models.CASCADE)
- registration_code = models.ForeignKey(CourseRegistrationCode, db_index=True, on_delete=models.CASCADE)
- redeemed_by = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
- redeemed_at = models.DateTimeField(auto_now_add=True, null=True)
- course_enrollment = models.ForeignKey(CourseEnrollment, null=True, on_delete=models.CASCADE)
-
- @classmethod
- def registration_code_used_for_enrollment(cls, course_enrollment):
- """
- Returns RegistrationCodeRedemption object if registration code
- has been used during the course enrollment else Returns None.
- """
- # theoretically there could be more than one (e.g. someone self-unenrolls
- # then re-enrolls with a different regcode)
- reg_codes = cls.objects.filter(course_enrollment=course_enrollment).order_by('-redeemed_at')
- if reg_codes:
- # return the first one. In all normal use cases of registration codes
- # the user will only have one
- return reg_codes[0]
-
- return None
-
- @classmethod
- def is_registration_code_redeemed(cls, course_reg_code):
- """
- Checks the existence of the registration code
- in the RegistrationCodeRedemption
- """
- return cls.objects.filter(registration_code__code=course_reg_code).exists()
-
- @classmethod
- def get_registration_code_redemption(cls, code, course_id):
- """
- Returns the registration code redemption object if found else returns None.
- """
- try:
- code_redemption = cls.objects.get(registration_code__code=code, registration_code__course_id=course_id)
- except cls.DoesNotExist:
- code_redemption = None
- return code_redemption
-
- @classmethod
- def create_invoice_generated_registration_redemption(cls, course_reg_code, user): # pylint: disable=invalid-name
- """
- This function creates a RegistrationCodeRedemption entry in case the registration codes were invoice generated
- and thus the order_id is missing.
- """
- code_redemption = RegistrationCodeRedemption(registration_code=course_reg_code, redeemed_by=user)
- code_redemption.save()
- return code_redemption
-
-
-@python_2_unicode_compatible
-class Coupon(models.Model):
- """
- This table contains coupon codes
- A user can get a discount offer on course if provide coupon code
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- code = models.CharField(max_length=32, db_index=True)
- description = models.CharField(max_length=255, null=True, blank=True)
- course_id = CourseKeyField(max_length=255)
- percentage_discount = models.IntegerField(default=0)
- created_by = models.ForeignKey(User, on_delete=models.CASCADE)
- created_at = models.DateTimeField(auto_now_add=True)
- is_active = models.BooleanField(default=True)
- expiration_date = models.DateTimeField(null=True, blank=True)
-
- def __str__(self):
- return "[Coupon] code: {} course: {}".format(self.code, self.course_id)
-
- @property
- def display_expiry_date(self):
- """
- return the coupon expiration date in the readable format
- """
- return (self.expiration_date - timedelta(days=1)).strftime(u"%B %d, %Y") if self.expiration_date else None
-
-
-class CouponRedemption(models.Model):
- """
- This table contain coupon redemption info
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- order = models.ForeignKey(Order, db_index=True, on_delete=models.CASCADE)
- user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
- coupon = models.ForeignKey(Coupon, db_index=True, on_delete=models.CASCADE)
-
- @classmethod
- def remove_code_redemption_from_item(cls, item, user):
- """
- If an item removed from shopping cart then we will remove
- the corresponding redemption info of coupon code
- """
- order_item_course_id = item.course_id
- try:
- # Try to remove redemption information of coupon code, If exist.
- coupon_redemption = cls.objects.get(
- user=user,
- coupon__course_id=order_item_course_id if order_item_course_id else CourseKeyField.Empty,
- order=item.order_id
- )
- coupon_redemption.delete()
- log.info(
- u'Coupon "%s" redemption entry removed for user "%s" for order item "%s"',
- coupon_redemption.coupon.code,
- user,
- str(item.id),
- )
- except CouponRedemption.DoesNotExist:
- log.debug(u'Code redemption does not exist for order item id=%s.', str(item.id))
-
- @classmethod
- def remove_coupon_redemption_from_cart(cls, user, cart):
- """
- This method delete coupon redemption
- """
- coupon_redemption = cls.objects.filter(user=user, order=cart)
- if coupon_redemption:
- coupon_redemption.delete()
- log.info(u'Coupon redemption entry removed for user %s for order %s', user, cart.id)
-
- @classmethod
- def get_discount_price(cls, percentage_discount, value):
- """
- return discounted price against coupon
- """
- discount = Decimal("{0:.2f}".format(Decimal(percentage_discount / 100.00) * value))
- return value - discount
-
- @classmethod
- def add_coupon_redemption(cls, coupon, order, cart_items):
- """
- add coupon info into coupon_redemption model
- """
- is_redemption_applied = False
- coupon_redemptions = cls.objects.filter(order=order, user=order.user)
- for coupon_redemption in coupon_redemptions:
- if coupon_redemption.coupon.code != coupon.code or coupon_redemption.coupon.id == coupon.id:
- log.exception(
- u"Coupon redemption already exist for user '%s' against order id '%s'",
- order.user.username,
- order.id,
- )
- raise MultipleCouponsNotAllowedException
-
- for item in cart_items:
- if item.course_id:
- if item.course_id == coupon.course_id:
- coupon_redemption = cls(order=order, user=order.user, coupon=coupon)
- coupon_redemption.save()
- discount_price = cls.get_discount_price(coupon.percentage_discount, item.unit_cost)
- item.list_price = item.unit_cost
- item.unit_cost = discount_price
- item.save()
- log.info(
- u"Discount generated for user %s against order id '%s'",
- order.user.username,
- order.id,
- )
- is_redemption_applied = True
- return is_redemption_applied
-
- return is_redemption_applied
-
- @classmethod
- def get_top_discount_codes_used(cls, course_id):
- """
- Returns the top discount codes used.
-
- QuerySet = [
- {
- 'coupon__percentage_discount': 22,
- 'coupon__code': '12',
- 'coupon__used_count': '2',
- },
- {
- ...
- }
- ]
- """
- return cls.objects.filter(order__status='purchased', coupon__course_id=course_id).values(
- 'coupon__code', 'coupon__percentage_discount'
- ).annotate(coupon__used_count=Count('coupon__code')).order_by('-coupon__used_count')
-
- @classmethod
- def get_total_coupon_code_purchases(cls, course_id):
- """
- returns total seats purchases using coupon codes
- """
- return cls.objects.filter(order__status='purchased', coupon__course_id=course_id).aggregate(Count('coupon'))
-
-
-class PaidCourseRegistration(OrderItem):
- """
- This is an inventory item for paying for a course registration
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- course_id = CourseKeyField(max_length=128, db_index=True)
- mode = models.SlugField(default=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG)
- course_enrollment = models.ForeignKey(CourseEnrollment, null=True, on_delete=models.CASCADE)
-
- @classmethod
- def get_self_purchased_seat_count(cls, course_key, status='purchased'):
- """
- returns the count of paid_course items filter by course_id and status.
- """
- return cls.objects.filter(course_id=course_key, status=status).count()
-
- @classmethod
- def get_course_item_for_user_enrollment(cls, user, course_id, course_enrollment):
- """
- Returns PaidCourseRegistration object if user has payed for
- the course enrollment else Returns None
- """
- try:
- return cls.objects.filter(course_id=course_id, user=user, course_enrollment=course_enrollment,
- status='purchased').latest('id')
- except PaidCourseRegistration.DoesNotExist:
- return None
-
- @classmethod
- def contained_in_order(cls, order, course_id):
- """
- Is the course defined by course_id contained in the order?
- """
- return course_id in [
- item.course_id
- for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")
- if isinstance(item, cls)
- ]
-
- @classmethod
- def get_total_amount_of_purchased_item(cls, course_key, status='purchased'):
- """
- This will return the total amount of money that a purchased course generated
- """
- total_cost = 0
- result = cls.objects.filter(course_id=course_key, status=status).aggregate(
- total=Sum(
- F('qty') * F('unit_cost'),
- output_field=models.DecimalField(decimal_places=2, max_digits=30)
- )
- )
-
- if result['total'] is not None:
- total_cost = result['total']
-
- return total_cost
-
- @classmethod
- @transaction.atomic
- def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG,
- cost=None, currency=None):
- """
- A standardized way to create these objects, with sensible defaults filled in.
- Will update the cost if called on an order that already carries the course.
-
- Returns the order item
- """
- # First a bunch of sanity checks:
- # actually fetch the course to make sure it exists, use this to
- # throw errors if it doesn't.
- course = modulestore().get_course(course_id)
- if not course:
- log.error(u"User {} tried to add non-existent course {} to cart id {}"
- .format(order.user.email, course_id, order.id))
- raise CourseDoesNotExistException
-
- if cls.contained_in_order(order, course_id):
- log.warning(
- u"User %s tried to add PaidCourseRegistration for course %s, already in cart id %s",
- order.user.email,
- course_id,
- order.id,
- )
- raise ItemAlreadyInCartException
-
- if CourseEnrollment.is_enrolled(user=order.user, course_key=course_id):
- log.warning(u"User {} trying to add course {} to cart id {}, already registered"
- .format(order.user.email, course_id, order.id))
- raise AlreadyEnrolledInCourseException
-
- ### Validations done, now proceed
- ### handle default arguments for mode_slug, cost, currency
- course_mode = CourseMode.mode_for_course(course_id, mode_slug)
- if not course_mode:
- # user could have specified a mode that's not set, in that case return the DEFAULT_MODE
- course_mode = CourseMode.DEFAULT_SHOPPINGCART_MODE
- if not cost:
- cost = course_mode.min_price
- if not currency:
- currency = course_mode.currency
-
- super(PaidCourseRegistration, cls).add_to_order(order, course_id, cost, currency=currency)
-
- item, __ = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id)
- item.status = order.status
- item.mode = course_mode.slug
- item.qty = 1
- item.unit_cost = cost
- item.list_price = cost
- item.line_desc = _(u'Registration for Course: {course_name}').format(
- course_name=course.display_name_with_default)
- item.currency = currency
- order.currency = currency
- item.report_comments = item.csv_report_comments
- order.save()
- item.save()
- log.info(u"User {} added course registration {} to cart: order {}"
- .format(order.user.email, course_id, order.id))
-
- CourseEnrollment.send_signal_full(EnrollStatusChange.paid_start,
- user=order.user, mode=item.mode, course_id=course_id,
- cost=cost, currency=currency)
- return item
-
- def purchased_callback(self):
- """
- When purchased, this should enroll the user in the course. We are assuming that
- course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found
- in CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment
- would in fact be quite silly since there's a clear back door.
- """
- if not modulestore().has_course(self.course_id):
- msg = u"The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)
- log.error(msg)
- raise PurchasedCallbackException(msg)
-
- # enroll in course and link to the enrollment_id
- self.course_enrollment = CourseEnrollment.enroll(user=self.user, course_key=self.course_id, mode=self.mode)
- self.save()
-
- log.info(u"Enrolled {0} in paid course {1}, paid ${2}"
- .format(self.user.email, self.course_id, self.line_cost))
- self.course_enrollment.send_signal(EnrollStatusChange.paid_complete,
- cost=self.line_cost, currency=self.currency)
-
- def generate_receipt_instructions(self):
- """
- Generates instructions when the user has purchased a PaidCourseRegistration.
- Basically tells the user to visit the dashboard to see their new classes
- """
- notification = Text(_(
- u"Please visit your {link_start}dashboard{link_end} "
- "to see your new course."
- )).format(
- link_start=HTML(u'').format(url=reverse('dashboard')),
- link_end=HTML(u''),
- )
-
- return self.pk_with_subclass, set([notification])
-
- @property
- def csv_report_comments(self):
- """
- Tries to fetch an annotation associated with the course_id from the database. If not found, returns u"".
- Otherwise returns the annotation
- """
- try:
- return PaidCourseRegistrationAnnotation.objects.get(course_id=self.course_id).annotation
- except PaidCourseRegistrationAnnotation.DoesNotExist:
- return u""
-
- def analytics_data(self):
- """Simple function used to construct analytics data for the OrderItem.
-
- If the Order Item is associated with a course, additional fields will be populated with
- course information. If there is a mode associated, the mode data is included in the SKU.
-
- Returns
- A dictionary containing analytics data for this OrderItem.
-
- """
- data = super(PaidCourseRegistration, self).analytics_data()
- sku = data['sku']
- if self.course_id != CourseKeyField.Empty:
- data['name'] = six.text_type(self.course_id)
- data['category'] = six.text_type(self.course_id.org)
- if self.mode:
- data['sku'] = sku + u'.' + six.text_type(self.mode)
- return data
-
-
-class CourseRegCodeItem(OrderItem):
- """
- This is an inventory item for paying for
- generating course registration codes
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- course_id = CourseKeyField(max_length=128, db_index=True)
- mode = models.SlugField(default=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG)
-
- @classmethod
- def get_bulk_purchased_seat_count(cls, course_key, status='purchased'):
- """
- returns the sum of bulk purchases seats.
- """
- total = 0
- result = cls.objects.filter(course_id=course_key, status=status).aggregate(total=Sum('qty'))
-
- if result['total'] is not None:
- total = result['total']
-
- return total
-
- @classmethod
- def contained_in_order(cls, order, course_id):
- """
- Is the course defined by course_id contained in the order?
- """
- return course_id in [
- item.course_id
- for item in order.orderitem_set.all().select_subclasses("courseregcodeitem")
- if isinstance(item, cls)
- ]
-
- @classmethod
- def get_total_amount_of_purchased_item(cls, course_key, status='purchased'):
- """
- This will return the total amount of money that a purchased course generated
- """
- total_cost = 0
- result = cls.objects.filter(course_id=course_key, status=status).aggregate(
- total=Sum(
- F('qty') * F('unit_cost'),
- output_field=models.DecimalField(decimal_places=2, max_digits=30)
- )
- )
-
- if result['total'] is not None:
- total_cost = result['total']
-
- return total_cost
-
- @classmethod
- @transaction.atomic
- def add_to_order(cls, order, course_id, qty, mode_slug=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG,
- cost=None, currency=None):
- """
- A standardized way to create these objects, with sensible defaults filled in.
- Will update the cost if called on an order that already carries the course.
-
- Returns the order item
- """
- # First a bunch of sanity checks:
- # actually fetch the course to make sure it exists, use this to
- # throw errors if it doesn't.
- course = modulestore().get_course(course_id)
- if not course:
- log.error(u"User {} tried to add non-existent course {} to cart id {}"
- .format(order.user.email, course_id, order.id))
- raise CourseDoesNotExistException
-
- if cls.contained_in_order(order, course_id):
- log.warning(u"User {} tried to add PaidCourseRegistration for course {}, already in cart id {}"
- .format(order.user.email, course_id, order.id))
- raise ItemAlreadyInCartException
-
- if CourseEnrollment.is_enrolled(user=order.user, course_key=course_id):
- log.warning(u"User {} trying to add course {} to cart id {}, already registered"
- .format(order.user.email, course_id, order.id))
- raise AlreadyEnrolledInCourseException
-
- ### Validations done, now proceed
- ### handle default arguments for mode_slug, cost, currency
- course_mode = CourseMode.mode_for_course(course_id, mode_slug)
- if not course_mode:
- # user could have specified a mode that's not set, in that case return the DEFAULT_SHOPPINGCART_MODE
- course_mode = CourseMode.DEFAULT_SHOPPINGCART_MODE
- if not cost:
- cost = course_mode.min_price
- if not currency:
- currency = course_mode.currency
-
- super(CourseRegCodeItem, cls).add_to_order(order, course_id, cost, currency=currency)
-
- item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) # pylint: disable=unused-variable
- item.status = order.status
- item.mode = course_mode.slug
- item.unit_cost = cost
- item.list_price = cost
- item.qty = qty
- item.line_desc = _(u'Enrollment codes for Course: {course_name}').format(
- course_name=course.display_name_with_default)
- item.currency = currency
- order.currency = currency
- item.report_comments = item.csv_report_comments
- order.save()
- item.save()
- log.info(u"User {} added course registration {} to cart: order {}"
- .format(order.user.email, course_id, order.id))
- return item
-
- def purchased_callback(self):
- """
- The purchase is completed, this OrderItem type will generate Registration Codes that will
- be redeemed by users
- """
- if not modulestore().has_course(self.course_id):
- msg = u"The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)
- log.error(msg)
- raise PurchasedCallbackException(msg)
- total_registration_codes = int(self.qty)
-
- # we need to import here because of a circular dependency
- # we should ultimately refactor code to have save_registration_code in this models.py
- # file, but there's also a shared dependency on a random string generator which
- # is in another PR (for another feature)
- from lms.djangoapps.instructor.views.api import save_registration_code
- for i in range(total_registration_codes):
- save_registration_code(self.user, self.course_id, self.mode, order=self.order)
-
- log.info(u"Enrolled {0} in paid course {1}, paid ${2}"
- .format(self.user.email, self.course_id, self.line_cost))
-
- @property
- def csv_report_comments(self):
- """
- Tries to fetch an annotation associated with the course_id from the database. If not found, returns u"".
- Otherwise returns the annotation
- """
- try:
- return CourseRegCodeItemAnnotation.objects.get(course_id=self.course_id).annotation
- except CourseRegCodeItemAnnotation.DoesNotExist:
- return u""
-
- def analytics_data(self):
- """Simple function used to construct analytics data for the OrderItem.
-
- If the OrderItem is associated with a course, additional fields will be populated with
- course information. If a mode is available, it will be included in the SKU.
-
- Returns
- A dictionary containing analytics data for this OrderItem.
-
- """
- data = super(CourseRegCodeItem, self).analytics_data()
- sku = data['sku']
- if self.course_id != CourseKeyField.Empty:
- data['name'] = six.text_type(self.course_id)
- data['category'] = six.text_type(self.course_id.org)
- if self.mode:
- data['sku'] = sku + u'.' + six.text_type(self.mode)
- return data
-
-
-@python_2_unicode_compatible
-class CourseRegCodeItemAnnotation(models.Model):
- """
- A model that maps course_id to an additional annotation. This is specifically needed because when Stanford
- generates report for the paid courses, each report item must contain the payment account associated with a course.
- And unfortunately we didn't have the concept of a "SKU" or stock item where we could keep this association,
- so this is to retrofit it.
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- course_id = CourseKeyField(unique=True, max_length=128, db_index=True)
- annotation = models.TextField(null=True)
-
- def __str__(self):
- return u"{} : {}".format(text_type(self.course_id), self.annotation)
-
-
-@python_2_unicode_compatible
-class PaidCourseRegistrationAnnotation(models.Model):
- """
- A model that maps course_id to an additional annotation. This is specifically needed because when Stanford
- generates report for the paid courses, each report item must contain the payment account associated with a course.
- And unfortunately we didn't have the concept of a "SKU" or stock item where we could keep this association,
- so this is to retrofit it.
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- course_id = CourseKeyField(unique=True, max_length=128, db_index=True)
- annotation = models.TextField(null=True)
-
- def __str__(self):
- return u"{} : {}".format(text_type(self.course_id), self.annotation)
-
-
-class CertificateItem(OrderItem):
- """
- This is an inventory item for purchasing certificates
-
- .. no_pii:
- """
- class Meta(object):
- app_label = "shoppingcart"
-
- course_id = CourseKeyField(max_length=128, db_index=True)
- course_enrollment = models.ForeignKey(CourseEnrollment, on_delete=models.CASCADE)
- mode = models.SlugField()
-
- @receiver(UNENROLL_DONE)
- def refund_cert_callback(sender, course_enrollment=None, skip_refund=False, **kwargs): # pylint: disable=no-self-argument,unused-argument
- """
- When a CourseEnrollment object calls its unenroll method, this function checks to see if that unenrollment
- occurred in a verified certificate that was within the refund deadline. If so, it actually performs the
- refund.
-
- Returns the refunded certificate on a successful refund; else, it returns nothing.
- """
-
- # Only refund verified cert unenrollments that are within bounds of the expiration date
- if skip_refund or (not course_enrollment.refundable()):
- return
-
- target_certs = CertificateItem.objects.filter(course_id=course_enrollment.course_id, user_id=course_enrollment.user, status='purchased', mode='verified')
- try:
- target_cert = target_certs[0]
- except IndexError:
- log.warning(
- u"Matching CertificateItem not found while trying to refund. User %s, Course %s",
- course_enrollment.user,
- course_enrollment.course_id,
- )
- return
- target_cert.status = 'refunded'
- target_cert.refund_requested_time = datetime.now(pytz.utc)
- target_cert.save()
-
- target_cert.order.refund()
-
- order_number = target_cert.order_id
- # send billing an email so they can handle refunding
- subject = _("[Refund] User-Requested Refund")
- message = u"User {user} ({user_email}) has requested a refund on Order #{order_number}.".format(user=course_enrollment.user,
- user_email=course_enrollment.user.email,
- order_number=order_number)
- to_email = [settings.PAYMENT_SUPPORT_EMAIL]
- from_email = configuration_helpers.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
- try:
- send_mail(subject, message, from_email, to_email, fail_silently=False)
- except Exception as exception: # pylint: disable=broad-except
- err_str = ('Failed sending email to billing to request a refund for verified certificate'
- u' (User {user}, Course {course}, CourseEnrollmentID {ce_id}, Order #{order})\n{exception}')
- log.error(err_str.format(
- user=course_enrollment.user,
- course=course_enrollment.course_id,
- ce_id=course_enrollment.id,
- order=order_number,
- exception=exception,
- ))
-
- return target_cert
-
- @classmethod
- @transaction.atomic
- def add_to_order(cls, order, course_id, cost, mode, currency='usd'):
- """
- Add a CertificateItem to an order
-
- Returns the CertificateItem object after saving
-
- `order` - an order that this item should be added to, generally the cart order
- `course_id` - the course that we would like to purchase as a CertificateItem
- `cost` - the amount the user will be paying for this CertificateItem
- `mode` - the course mode that this certificate is going to be issued for
-
- This item also creates a new enrollment if none exists for this user and this course.
-
- Example Usage:
- cart = Order.get_cart_for_user(user)
- CertificateItem.add_to_order(cart, 'edX/Test101/2013_Fall', 30, 'verified')
-
- """
- super(CertificateItem, cls).add_to_order(order, course_id, cost, currency=currency)
-
- course_enrollment = CourseEnrollment.get_or_create_enrollment(order.user, course_id)
-
- # do some validation on the enrollment mode
- valid_modes = CourseMode.modes_for_course_dict(course_id)
- if mode in valid_modes:
- mode_info = valid_modes[mode]
- else:
- msg = u"Mode {mode} does not exist for {course_id}".format(mode=mode, course_id=course_id)
- log.error(msg)
- raise InvalidCartItem(
- _(u"Mode {mode} does not exist for {course_id}").format(mode=mode, course_id=course_id)
- )
-
- item, _created = cls.objects.get_or_create(
- order=order,
- user=order.user,
- course_id=course_id,
- course_enrollment=course_enrollment,
- mode=mode,
- )
- item.status = order.status
- item.qty = 1
- item.unit_cost = cost
- item.list_price = cost
- course_name = modulestore().get_course(course_id).display_name
- # Translators: In this particular case, mode_name refers to a
- # particular mode (i.e. Honor Code Certificate, Verified Certificate, etc)
- # by which a user could enroll in the given course.
- item.line_desc = _(u"{mode_name} for course {course}").format(
- mode_name=mode_info.name,
- course=course_name
- )
- item.currency = currency
- order.currency = currency
- order.save()
- item.save()
-
- # signal course added to cart
- course_enrollment.send_signal(EnrollStatusChange.paid_start, cost=cost, currency=currency)
- return item
-
- def purchased_callback(self):
- """
- When purchase goes through, activate and update the course enrollment for the correct mode
- """
- self.course_enrollment.change_mode(self.mode)
- self.course_enrollment.activate()
- self.course_enrollment.send_signal(EnrollStatusChange.upgrade_complete,
- cost=self.unit_cost, currency=self.currency)
-
- def additional_instruction_text(self):
- verification_reminder = ""
- domain = configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME)
-
- dashboard_path = reverse('dashboard')
- scheme = u"https" if settings.HTTPS == "on" else u"http"
- dashboard_url = "{scheme}://{domain}{path}".format(scheme=scheme, domain=domain, path=dashboard_path)
- refund_reminder_msg = _("To receive a refund you may unenroll from the course on your edX Dashboard "
- "({dashboard_url}) up to 14 days after your payment or 14 days after your"
- " course starts (up to six months after your payment).\n"
- ).format(dashboard_url=dashboard_url)
-
- is_enrollment_mode_verified = self.course_enrollment.is_verified_enrollment()
- is_professional_mode_verified = self.course_enrollment.is_professional_enrollment()
-
- if is_enrollment_mode_verified:
- path = reverse('verify_student_verify_now', kwargs={'course_id': six.text_type(self.course_id)})
- verification_url = "http://{domain}{path}".format(domain=domain, path=path)
-
- verification_reminder = _(
- u"If you haven't verified your identity yet, please start the verification process"
- u" ({verification_url}).").format(verification_url=verification_url)
-
- if is_professional_mode_verified:
- refund_reminder_msg = _("You can unenroll in the course and receive a full refund for 2 days after the "
- "course start date.\n")
- refund_reminder = _(
- "{refund_reminder_msg}"
- "For help unenrolling, Please see How do I unenroll from a course? "
- "({how_to_unenroll_link}) in our edX HelpCenter.").format(
- refund_reminder_msg=refund_reminder_msg,
- how_to_unenroll_link=settings.SUPPORT_HOW_TO_UNENROLL_LINK
- )
-
- # Need this to be unicode in case the reminder strings
- # have been translated and contain non-ASCII unicode
- return u"{verification_reminder} {refund_reminder}".format(
- verification_reminder=verification_reminder,
- refund_reminder=refund_reminder
- )
-
- @classmethod
- def verified_certificates_count(cls, course_id, status):
- """Return a queryset of CertificateItem for every verified enrollment in course_id with the given status."""
- return use_read_replica_if_available(
- CertificateItem.objects.filter(course_id=course_id, mode='verified', status=status).count())
-
- # TODO combine these three methods into one
- @classmethod
- def verified_certificates_monetary_field_sum(cls, course_id, status, field_to_aggregate):
- """
- Returns a Decimal indicating the total sum of field_to_aggregate for all verified certificates with a particular status.
-
- Sample usages:
- - status 'refunded' and field_to_aggregate 'unit_cost' will give the total amount of money refunded for course_id
- - status 'purchased' and field_to_aggregate 'service_fees' gives the sum of all service fees for purchased certificates
- etc
- """
- query = use_read_replica_if_available(
- CertificateItem.objects.filter(course_id=course_id, mode='verified', status=status)).aggregate(Sum(field_to_aggregate))[field_to_aggregate + '__sum']
- if query is None:
- return Decimal(0.00)
- else:
- return query
-
- @classmethod
- def verified_certificates_contributing_more_than_minimum(cls, course_id):
- return use_read_replica_if_available(
- CertificateItem.objects.filter(
- course_id=course_id,
- mode='verified',
- status='purchased',
- unit_cost__gt=(CourseMode.min_course_price_for_verified_for_currency(course_id, 'usd')))).count()
-
- def analytics_data(self):
- """Simple function used to construct analytics data for the OrderItem.
-
- If the CertificateItem is associated with a course, additional fields will be populated with
- course information. If there is a mode associated with the certificate, it is included in the SKU.
-
- Returns
- A dictionary containing analytics data for this OrderItem.
-
- """
- data = super(CertificateItem, self).analytics_data()
- sku = data['sku']
- if self.course_id != CourseKeyField.Empty:
- data['name'] = six.text_type(self.course_id)
- data['category'] = six.text_type(self.course_id.org)
- if self.mode:
- data['sku'] = sku + u'.' + six.text_type(self.mode)
- return data
-
-
-class DonationConfiguration(ConfigurationModel):
- """
- Configure whether donations are enabled on the site.
-
- .. no_pii:
- """
- class Meta(ConfigurationModel.Meta):
- app_label = "shoppingcart"
-
-
-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.
-
- .. no_pii:
- """
-
- class Meta(object):
- app_label = "shoppingcart"
-
- # Types of donations
- DONATION_TYPES = (
- (u"general", u"A general donation"),
- (u"course", u"A donation to a particular course")
- )
-
- # The type of donation
- donation_type = models.CharField(max_length=32, default=u"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.atomic
- 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()])
-
- def additional_instruction_text(self, **kwargs):
- """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"We greatly appreciate this generous contribution and your support of the {platform_name} mission. "
- u"This receipt was prepared to support charitable contributions for tax purposes. "
- u"We confirm that neither goods nor services were provided in exchange for this gift."
- ).format(platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME))
-
- @classmethod
- def _line_item_description(cls, course_id=None):
- """Create a line-item description for the donation.
-
- Includes the course display name if provided.
-
- Keyword Arguments:
- course_id (CourseKey)
-
- Raises:
- CourseDoesNotExistException: 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:
- msg = u"Could not find a course with the ID '{course_id}'".format(course_id=course_id)
- log.error(msg)
- raise CourseDoesNotExistException(
- _(u"Could not find a course with the ID '{course_id}'").format(course_id=course_id)
- )
-
- 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 for {platform_name}").format(
- platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
- )
-
- @property
- def single_item_receipt_context(self):
- return {
- 'receipt_has_donation_item': True,
- }
-
- def analytics_data(self):
- """Simple function used to construct analytics data for the OrderItem.
-
- If the donation is associated with a course, additional fields will be populated with
- course information. When no name or category is specified by the implementation, the
- platform name is used as a default value for required event fields, to declare that
- the Order is specific to the platform, rather than a specific product name or category.
-
- Returns
- A dictionary containing analytics data for this OrderItem.
-
- """
- data = super(Donation, self).analytics_data()
- if self.course_id != CourseKeyField.Empty:
- data['name'] = six.text_type(self.course_id)
- data['category'] = six.text_type(self.course_id.org)
- else:
- data['name'] = configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
- data['category'] = configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
- return data
-
- @property
- def pdf_receipt_display_name(self):
- """
- How to display this item on a PDF printed receipt file.
- """
- return self._line_item_description(course_id=self.course_id)
diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource2.py b/lms/djangoapps/shoppingcart/processors/CyberSource2.py
deleted file mode 100644
index cafde02aa8..0000000000
--- a/lms/djangoapps/shoppingcart/processors/CyberSource2.py
+++ /dev/null
@@ -1,725 +0,0 @@
-"""
-Implementation of the CyberSource credit card processor using the newer "Secure Acceptance API".
-The previous Hosted Order Page API is being deprecated as of 9/14.
-
-For now, we're keeping the older implementation in the code-base so we can
-quickly roll-back by updating the configuration. Eventually, we should replace
-the original implementation with this version.
-
-To enable this implementation, add the following Django settings:
-
- CC_PROCESSOR_NAME = "CyberSource2"
- CC_PROCESSOR = {
- "CyberSource2": {
- "SECRET_KEY": "",
- "ACCESS_KEY": "",
- "PROFILE_ID": "",
- "PURCHASE_ENDPOINT": ""
- }
- }
-
-"""
-
-
-import binascii
-import hmac
-import json
-import logging
-import re
-import uuid
-from collections import OrderedDict, defaultdict
-from datetime import datetime
-from decimal import Decimal, InvalidOperation
-from hashlib import sha256
-from textwrap import dedent
-
-from django.conf import settings
-from django.utils.translation import ugettext as _
-from django.utils.translation import ugettext_noop
-from six import text_type
-
-from edxmako.shortcuts import render_to_string
-from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
-from openedx.core.djangolib.markup import HTML, Text
-from shoppingcart.models import Order
-from shoppingcart.processors.exceptions import *
-from shoppingcart.processors.helpers import get_processor_config
-
-log = logging.getLogger(__name__)
-
-# Translators: this text appears when an unfamiliar error code occurs during payment,
-# for which we don't know a user-friendly message to display in advance.
-DEFAULT_REASON = ugettext_noop("UNKNOWN REASON")
-
-
-def process_postpay_callback(params):
- """
- Handle a response from the payment processor.
-
- Concrete implementations should:
- 1) Verify the parameters and determine if the payment was successful.
- 2) If successful, mark the order as purchased and call `purchased_callbacks` of the cart items.
- 3) If unsuccessful, try to figure out why and generate a helpful error message.
- 4) Return a dictionary of the form:
- {'success': bool, 'order': Order, 'error_html': str}
-
- Args:
- params (dict): Dictionary of parameters received from the payment processor.
-
- Keyword Args:
- Can be used to provide additional information to concrete implementations.
-
- Returns:
- dict
-
- """
- try:
- valid_params = verify_signatures(params)
- result = _payment_accepted(
- valid_params['req_reference_number'],
- valid_params['auth_amount'],
- valid_params['req_currency'],
- valid_params['decision']
- )
- if result['accepted']:
- _record_purchase(params, result['order'])
- return {
- 'success': True,
- 'order': result['order'],
- 'error_html': ''
- }
- else:
- _record_payment_info(params, result['order'])
- return {
- 'success': False,
- 'order': result['order'],
- 'error_html': _get_processor_decline_html(params)
- }
- except CCProcessorException as error:
- log.exception('error processing CyberSource postpay callback')
- # if we have the order and the id, log it
- if hasattr(error, 'order'):
- _record_payment_info(params, error.order)
- else:
- log.info(json.dumps(params))
- return {
- 'success': False,
- 'order': None, # due to exception we may not have the order
- 'error_html': _get_processor_exception_html(error)
- }
-
-
-def processor_hash(value):
- """
- Calculate the base64-encoded, SHA-256 hash used by CyberSource.
-
- Args:
- value (string): The value to encode.
-
- Returns:
- string
-
- """
- secret_key = get_processor_config().get('SECRET_KEY', '')
- hash_obj = hmac.new(secret_key.encode('utf-8'), value.encode('utf-8'), sha256)
- signature = binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want
- return signature.decode('utf-8')
-
-
-def verify_signatures(params):
- """
- Use the signature we receive in the POST back from CyberSource to verify
- the identity of the sender (CyberSource) and that the contents of the message
- have not been tampered with.
-
- Args:
- params (dictionary): The POST parameters we received from CyberSource.
-
- Returns:
- dict: Contains the parameters we will use elsewhere, converted to the
- appropriate types
-
- Raises:
- CCProcessorSignatureException: The calculated signature does not match
- the signature we received.
-
- CCProcessorDataException: The parameters we received from CyberSource were not valid
- (missing keys, wrong types)
-
- """
-
- # First see if the user cancelled the transaction
- # if so, then not all parameters will be passed back so we can't yet verify signatures
- if params.get('decision') == u'CANCEL':
- raise CCProcessorUserCancelled()
-
- # if the user decline the transaction
- # if so, then auth_amount will not be passed back so we can't yet verify signatures
- if params.get('decision') == u'DECLINE':
- raise CCProcessorUserDeclined()
-
- # Validate the signature to ensure that the message is from CyberSource
- # and has not been tampered with.
- signed_fields = params.get('signed_field_names', '').split(',')
- data = u",".join([u"{0}={1}".format(k, params.get(k, '')) for k in signed_fields])
- returned_sig = params.get('signature', '')
- if processor_hash(data) != returned_sig:
- raise CCProcessorSignatureException()
-
- # Validate that we have the paramters we expect and can convert them
- # to the appropriate types.
- # Usually validating the signature is sufficient to validate that these
- # fields exist, but since we're relying on CyberSource to tell us
- # which fields they included in the signature, we need to be careful.
- valid_params = {}
- required_params = [
- ('req_reference_number', int),
- ('req_currency', str),
- ('decision', str),
- ('auth_amount', Decimal),
- ]
- for key, key_type in required_params:
- if key not in params:
- raise CCProcessorDataException(
- _(
- u"The payment processor did not return a required parameter: {parameter}"
- ).format(parameter=key)
- )
- try:
- valid_params[key] = key_type(params[key])
- except (ValueError, TypeError, InvalidOperation):
- raise CCProcessorDataException(
- _(
- u"The payment processor returned a badly-typed value {value} for parameter {parameter}."
- ).format(value=params[key], parameter=key)
- )
-
- return valid_params
-
-
-def sign(params):
- """
- Sign the parameters dictionary so CyberSource can validate our identity.
-
- The params dict should contain a key 'signed_field_names' that is a comma-separated
- list of keys in the dictionary. The order of this list is important!
-
- Args:
- params (dict): Dictionary of parameters; must include a 'signed_field_names' key
-
- Returns:
- dict: The same parameters dict, with a 'signature' key calculated from the other values.
-
- """
- fields = u",".join(list(params.keys()))
- params['signed_field_names'] = fields
-
- signed_fields = params.get('signed_field_names', '').split(',')
- values = u",".join([u"{0}={1}".format(i, params.get(i, '')) for i in signed_fields])
- params['signature'] = processor_hash(values)
- params['signed_field_names'] = fields
-
- return params
-
-
-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
-
- Args:
- cart (Order): The order model representing items in the user's cart.
-
- Keyword Args:
- callback_url (unicode): The URL that CyberSource should POST to when the user
- completes a purchase. If not provided, then CyberSource will use
- 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, extra_data=extra_data
- ),
- })
-
-
-def get_signed_purchase_params(cart, callback_url=None, extra_data=None):
- """
- This method will return a digitally signed set of CyberSource parameters
-
- Args:
- cart (Order): The order model representing items in the user's cart.
-
- Keyword Args:
- callback_url (unicode): The URL that CyberSource should POST to when the user
- completes a purchase. If not provided, then CyberSource will use
- 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, extra_data=extra_data))
-
-
-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
-
- Args:
- cart (Order): The order model representing items in the user's cart.
-
- Keyword Args:
- callback_url (unicode): The URL that CyberSource should POST to when the user
- completes a purchase. If not provided, then CyberSource will use
- 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
-
- """
- total_cost = cart.total_cost
- amount = "{0:0.2f}".format(total_cost)
- params = OrderedDict()
-
- params['amount'] = amount
- params['currency'] = cart.currency
- params['orderNumber'] = u"OrderId: {0:d}".format(cart.id)
-
- params['access_key'] = get_processor_config().get('ACCESS_KEY', '')
- params['profile_id'] = get_processor_config().get('PROFILE_ID', '')
- params['reference_number'] = cart.id
- params['transaction_type'] = 'sale'
-
- params['locale'] = 'en'
- params['signed_date_time'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
- params['signed_field_names'] = 'access_key,profile_id,amount,currency,transaction_type,reference_number,signed_date_time,locale,transaction_uuid,signed_field_names,unsigned_field_names,orderNumber'
- params['unsigned_field_names'] = ''
- params['transaction_uuid'] = uuid.uuid4().hex
- params['payment_method'] = 'card'
-
- if callback_url is not 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
-
-
-def get_purchase_endpoint():
- """
- Return the URL of the payment end-point for CyberSource.
-
- Returns:
- unicode
-
- """
- return get_processor_config().get('PURCHASE_ENDPOINT', '')
-
-
-def _payment_accepted(order_id, auth_amount, currency, decision):
- """
- Check that CyberSource has accepted the payment.
-
- Args:
- order_num (int): The ID of the order associated with this payment.
- auth_amount (Decimal): The amount the user paid using CyberSource.
- currency (str): The currency code of the payment.
- decision (str): "ACCEPT" if the payment was accepted.
-
- Returns:
- dictionary of the form:
- {
- 'accepted': bool,
- 'amnt_charged': int,
- 'currency': string,
- 'order': Order
- }
-
- Raises:
- CCProcessorDataException: The order does not exist.
- CCProcessorWrongAmountException: The user did not pay the correct amount.
-
- """
- try:
- order = Order.objects.get(id=order_id)
- except Order.DoesNotExist:
- raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system."))
-
- if decision == 'ACCEPT':
- if auth_amount == order.total_cost and currency.lower() == order.currency.lower():
- return {
- 'accepted': True,
- 'amt_charged': auth_amount,
- 'currency': currency,
- 'order': order
- }
- else:
- ex = CCProcessorWrongAmountException(
- _(
- u"The amount charged by the processor {charged_amount} {charged_amount_currency} is different "
- u"than the total cost of the order {total_cost} {total_cost_currency}."
- ).format(
- charged_amount=auth_amount,
- charged_amount_currency=currency,
- total_cost=order.total_cost,
- total_cost_currency=order.currency
- )
- )
-
- ex.order = order
- raise ex
- else:
- return {
- 'accepted': False,
- 'amt_charged': 0,
- 'currency': 'usd',
- 'order': order
- }
-
-
-def _record_purchase(params, order):
- """
- Record the purchase and run purchased_callbacks
-
- Args:
- params (dict): The parameters we received from CyberSource.
- order (Order): The order associated with this payment.
-
- Returns:
- None
-
- """
- # Usually, the credit card number will have the form "xxxxxxxx1234"
- # Parse the string to retrieve the digits.
- # If we can't find any digits, use placeholder values instead.
- ccnum_str = params.get('req_card_number', '')
- first_digit = re.search(r"\d", ccnum_str)
- if first_digit:
- ccnum = ccnum_str[first_digit.start():]
- else:
- ccnum = "####"
-
- if settings.FEATURES.get("LOG_POSTPAY_CALLBACKS"):
- log.info(
- u"Order %d purchased with params: %s", order.id, json.dumps(params)
- )
-
- # Mark the order as purchased and store the billing information
- order.purchase(
- first=params.get('req_bill_to_forename', ''),
- last=params.get('req_bill_to_surname', ''),
- street1=params.get('req_bill_to_address_line1', ''),
- street2=params.get('req_bill_to_address_line2', ''),
- city=params.get('req_bill_to_address_city', ''),
- state=params.get('req_bill_to_address_state', ''),
- country=params.get('req_bill_to_address_country', ''),
- postalcode=params.get('req_bill_to_address_postal_code', ''),
- ccnum=ccnum,
- cardtype=CARDTYPE_MAP[params.get('req_card_type', '')],
- processor_reply_dump=json.dumps(params)
- )
-
-
-def _record_payment_info(params, order):
- """
- Record the purchase and run purchased_callbacks
-
- Args:
- params (dict): The parameters we received from CyberSource.
-
- Returns:
- None
- """
-
- if settings.FEATURES.get("LOG_POSTPAY_CALLBACKS"):
- log.info(
- u"Order %d processed (but not completed) with params: %s", order.id, json.dumps(params)
- )
-
- order.processor_reply_dump = json.dumps(params)
- order.save()
-
-
-def _get_processor_decline_html(params):
- """
- Return HTML indicating that the user's payment was declined.
-
- Args:
- params (dict): Parameters we received from CyberSource.
-
- Returns:
- unicode: The rendered HTML.
-
- """
- payment_support_email = configuration_helpers.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
- return _format_error_html(
- Text(_(
- u"Sorry! Our payment processor did not accept your payment. "
- u"The decision they returned was {decision}, "
- u"and the reason was {reason}. "
- u"You were not charged. Please try a different form of payment. "
- u"Contact us with payment-related questions at {email}."
- )).format(
- decision=HTML(u'{decision}').format(decision=params['decision']),
- reason=HTML(u'{reason_code}:{reason_msg}').format(
- reason_code=params['reason_code'],
- reason_msg=REASONCODE_MAP.get(params['reason_code'])
- ),
- email=payment_support_email
- )
- )
-
-
-def _get_processor_exception_html(exception):
- """
- Return HTML indicating that an error occurred.
-
- Args:
- exception (CCProcessorException): The exception that occurred.
-
- Returns:
- unicode: The rendered HTML.
-
- """
- payment_support_email = configuration_helpers.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
- if isinstance(exception, CCProcessorDataException):
- return _format_error_html(
- Text(_(
- u"Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! "
- u"We apologize that we cannot verify whether the charge went through and take further action on your order. "
- u"The specific error message is: {msg} "
- u"Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}."
- )).format(
- msg=HTML(u'{msg}').format(msg=text_type(exception)),
- email=payment_support_email
- )
- )
- elif isinstance(exception, CCProcessorWrongAmountException):
- return _format_error_html(
- Text(_(
- u"Sorry! Due to an error your purchase was charged for a different amount than the order total! "
- u"The specific error message is: {msg}. "
- u"Your credit card has probably been charged. Contact us with payment-specific questions at {email}."
- )).format(
- msg=HTML(u'{msg}').format(msg=text_type(exception)),
- email=payment_support_email
- )
- )
- elif isinstance(exception, CCProcessorSignatureException):
- return _format_error_html(
- Text(_(
- u"Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are "
- u"unable to validate that the message actually came from the payment processor. "
- u"The specific error message is: {msg}. "
- u"We apologize that we cannot verify whether the charge went through and take further action on your order. "
- u"Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}."
- )).format(
- msg=HTML(u'{msg}').format(msg=text_type(exception)),
- email=payment_support_email
- )
- )
- elif isinstance(exception, CCProcessorUserCancelled):
- return _format_error_html(
- _(
- u"Sorry! Our payment processor sent us back a message saying that you have cancelled this transaction. "
- u"The items in your shopping cart will exist for future purchase. "
- u"If you feel that this is in error, please contact us with payment-specific questions at {email}."
- ).format(
- email=payment_support_email
- )
- )
- elif isinstance(exception, CCProcessorUserDeclined):
- return _format_error_html(
- _(
- u"We're sorry, but this payment was declined. The items in your shopping cart have been saved. "
- u"If you have any questions about this transaction, please contact us at {email}."
- ).format(
- email=payment_support_email
- )
- )
- else:
- return _format_error_html(
- _(
- u"Sorry! Your payment could not be processed because an unexpected exception occurred. "
- u"Please contact us at {email} for assistance."
- ).format(email=payment_support_email)
- )
-
-
-def _format_error_html(msg):
- """ Format an HTML error message """
- return HTML(u'
{msg}
').format(msg=msg)
-
-
-CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN")
-CARDTYPE_MAP.update(
- {
- '001': 'Visa',
- '002': 'MasterCard',
- '003': 'American Express',
- '004': 'Discover',
- '005': 'Diners Club',
- '006': 'Carte Blanche',
- '007': 'JCB',
- '014': 'EnRoute',
- '021': 'JAL',
- '024': 'Maestro',
- '031': 'Delta',
- '033': 'Visa Electron',
- '034': 'Dankort',
- '035': 'Laser',
- '036': 'Carte Bleue',
- '037': 'Carta Si',
- '042': 'Maestro Int.',
- '043': 'GE Money UK card'
- }
-)
-
-
-# Note: these messages come directly from official Cybersource documentation at:
-# http://apps.cybersource.com/library/documentation/dev_guides/CC_Svcs_SO_API/html/wwhelp/wwhimpl/js/html/wwhelp.htm#href=reason_codes.html
-REASONCODE_MAP = defaultdict(lambda: DEFAULT_REASON)
-REASONCODE_MAP.update(
- {
- '100': _('Successful transaction.'),
- '101': _('The request is missing one or more required fields.'),
- '102': _('One or more fields in the request contains invalid data.'),
- '104': dedent(_(
- """
- The merchant reference code for this authorization request matches the merchant reference code of another
- authorization request that you sent within the past 15 minutes.
- Possible action: Resend the request with a unique merchant reference code.
- """)),
- '110': _('Only a partial amount was approved.'),
- '150': _('General system failure.'),
- '151': dedent(_(
- """
- The request was received but there was a server timeout. This error does not include timeouts between the
- client and the server.
- """)),
- '152': _('The request was received, but a service did not finish running in time.'),
- '200': dedent(_(
- """
- The authorization request was approved by the issuing bank but declined by CyberSource
- because it did not pass the Address Verification System (AVS).
- """)),
- '201': dedent(_(
- """
- The issuing bank has questions about the request. You do not receive an
- authorization code programmatically, but you might receive one verbally by calling the processor.
- Possible action: retry with another form of payment.
- """)),
- '202': dedent(_(
- """
- Expired card. You might also receive this if the expiration date you
- provided does not match the date the issuing bank has on file.
- Possible action: retry with another form of payment.
- """)),
- '203': dedent(_(
- """
- General decline of the card. No other information provided by the issuing bank.
- Possible action: retry with another form of payment.
- """)),
- '204': _('Insufficient funds in the account. Possible action: retry with another form of payment.'),
- # 205 was Stolen or lost card. Might as well not show this message to the person using such a card.
- '205': _('Stolen or lost card.'),
- '207': _('Issuing bank unavailable. Possible action: retry again after a few minutes.'),
- '208': dedent(_(
- """
- Inactive card or card not authorized for card-not-present transactions.
- Possible action: retry with another form of payment.
- """)),
- '209': _('CVN did not match.'),
- '210': _('The card has reached the credit limit. Possible action: retry with another form of payment.'),
- '211': _('Invalid card verification number (CVN). Possible action: retry with another form of payment.'),
- # 221 was The customer matched an entry on the processor's negative file.
- # Might as well not show this message to the person using such a card.
- '221': _('The customer matched an entry on the processors negative file.'),
- '222': _('Account frozen. Possible action: retry with another form of payment.'),
- '230': dedent(_(
- """
- The authorization request was approved by the issuing bank but declined by
- CyberSource because it did not pass the CVN check.
- Possible action: retry with another form of payment.
- """)),
- '231': _('Invalid account number. Possible action: retry with another form of payment.'),
- '232': dedent(_(
- """
- The card type is not accepted by the payment processor.
- Possible action: retry with another form of payment.
- """)),
- '233': _('General decline by the processor. Possible action: retry with another form of payment.'),
- '234': _(
- u"There is a problem with the information in your CyberSource account. Please let us know at {0}"
- ).format(settings.PAYMENT_SUPPORT_EMAIL),
- '235': _('The requested capture amount exceeds the originally authorized amount.'),
- '236': _('Processor Failure. Possible action: retry the payment'),
- '237': _('The authorization has already been reversed.'),
- '238': _('The authorization has already been captured.'),
- '239': _('The requested transaction amount must match the previous transaction amount.'),
- '240': dedent(_(
- """
- The card type sent is invalid or does not correlate with the credit card number.
- Possible action: retry with the same card or another form of payment.
- """)),
- '241': _('The request ID is invalid.'),
- '242': dedent(_(
- """
- You requested a capture, but there is no corresponding, unused authorization record. Occurs if there was
- not a previously successful authorization request or if the previously successful authorization has already
- been used by another capture request.
- """)),
- '243': _('The transaction has already been settled or reversed.'),
- '246': dedent(_(
- """
- Either the capture or credit is not voidable because the capture or credit information has already been
- submitted to your processor, or you requested a void for a type of transaction that cannot be voided.
- """)),
- '247': _('You requested a credit for a capture that was previously voided.'),
- '250': _('The request was received, but there was a timeout at the payment processor.'),
- '254': _('Stand-alone credits are not allowed.'),
- '475': _('The cardholder is enrolled for payer authentication'),
- '476': _('Payer authentication could not be authenticated'),
- '520': dedent(_(
- """
- The authorization request was approved by the issuing bank but declined by CyberSource based
- on your legacy Smart Authorization settings.
- Possible action: retry with a different form of payment.
- """)),
- }
-)
-
-
-def is_user_payment_error(reason_code):
- """
- Decide, based on the reason_code, whether or not it signifies a problem
- with something the user did (rather than a system error beyond the user's
- control).
-
- This function is used to determine whether we can/should show the user a
- message with suggested actions to fix the problem, or simply apologize and
- ask her to try again later.
- """
- reason_code = str(reason_code)
- if reason_code not in REASONCODE_MAP or REASONCODE_MAP[reason_code] == DEFAULT_REASON:
- return False
-
- return (200 <= int(reason_code) <= 233) or int(reason_code) in (101, 102, 240)
diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py
deleted file mode 100644
index d08b7163c4..0000000000
--- a/lms/djangoapps/shoppingcart/processors/__init__.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-Public API for payment processor implementations.
-The specific implementation is determined at runtime using Django settings:
-
- CC_PROCESSOR_NAME: The name of the Python module (in `shoppingcart.processors`) to use.
-
- CC_PROCESSOR: Dictionary of configuration options for specific processor implementations,
- keyed to processor names.
-
-"""
-
-
-from django.conf import settings
-
-# Import the processor implementation, using `CC_PROCESSOR_NAME`
-# as the name of the Python module in `shoppingcart.processors`
-PROCESSOR_MODULE = __import__(
- 'shoppingcart.processors.' + settings.CC_PROCESSOR_NAME,
- fromlist=[
- 'render_purchase_form_html',
- 'process_postpay_callback',
- 'get_purchase_endpoint',
- 'get_signed_purchase_params',
- ]
-)
-
-
-def render_purchase_form_html(cart, **kwargs):
- """
- Render an HTML form with POSTs to the hosted payment processor.
-
- Args:
- cart (Order): The order model representing items in the user's cart.
-
- Returns:
- unicode: the rendered HTML form
-
- """
- return PROCESSOR_MODULE.render_purchase_form_html(cart, **kwargs)
-
-
-def process_postpay_callback(params, **kwargs):
- """
- Handle a response from the payment processor.
-
- Concrete implementations should:
- 1) Verify the parameters and determine if the payment was successful.
- 2) If successful, mark the order as purchased and call `purchased_callbacks` of the cart items.
- 3) If unsuccessful, try to figure out why and generate a helpful error message.
- 4) Return a dictionary of the form:
- {'success': bool, 'order': Order, 'error_html': str}
-
- Args:
- params (dict): Dictionary of parameters received from the payment processor.
-
- Keyword Args:
- Can be used to provide additional information to concrete implementations.
-
- Returns:
- dict
-
- """
- return PROCESSOR_MODULE.process_postpay_callback(params, **kwargs)
-
-
-def get_purchase_endpoint():
- """
- Return the URL of the current payment processor's endpoint.
-
- Returns:
- unicode
-
- """
- return PROCESSOR_MODULE.get_purchase_endpoint()
-
-
-def get_signed_purchase_params(cart, **kwargs):
- """
- Return the parameters to send to the current payment processor.
-
- Args:
- cart (Order): The order model representing items in the user's cart.
-
- Keyword Args:
- Can be used to provide additional information to concrete implementations.
-
- Returns:
- dict
-
- """
- return PROCESSOR_MODULE.get_signed_purchase_params(cart, **kwargs)
diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py
deleted file mode 100644
index 25cd397225..0000000000
--- a/lms/djangoapps/shoppingcart/processors/exceptions.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""
-Payment processing exceptions
-"""
-
-
-from shoppingcart.exceptions import PaymentException
-
-
-class CCProcessorException(PaymentException):
- pass
-
-
-class CCProcessorSignatureException(CCProcessorException):
- pass
-
-
-class CCProcessorDataException(CCProcessorException):
- pass
-
-
-class CCProcessorWrongAmountException(CCProcessorException):
- pass
-
-
-class CCProcessorUserCancelled(CCProcessorException):
- pass
-
-
-class CCProcessorUserDeclined(CCProcessorException):
- """Transaction declined."""
- pass
diff --git a/lms/djangoapps/shoppingcart/processors/helpers.py b/lms/djangoapps/shoppingcart/processors/helpers.py
deleted file mode 100644
index 79bf23b5ec..0000000000
--- a/lms/djangoapps/shoppingcart/processors/helpers.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""
-Helper methods for credit card processing modules.
-These methods should be shared among all processor implementations,
-but should NOT be imported by modules outside this package.
-"""
-
-
-from django.conf import settings
-
-from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
-
-
-def get_processor_config():
- """
- Return a dictionary of configuration settings for the active credit card processor.
- If configuration overrides are available, return those instead.
-
- Returns:
- dict
-
- """
- # Retrieve the configuration settings for the active credit card processor
- config = settings.CC_PROCESSOR.get(
- settings.CC_PROCESSOR_NAME, {}
- )
- return config
diff --git a/lms/djangoapps/shoppingcart/processors/tests/__init__.py b/lms/djangoapps/shoppingcart/processors/tests/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py
deleted file mode 100644
index de691f96ec..0000000000
--- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py
+++ /dev/null
@@ -1,443 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-Tests for the newer CyberSource API implementation.
-"""
-
-
-import ddt
-from django.conf import settings
-from django.test import TestCase
-from mock import patch
-
-from ...models import Order, OrderItem
-from ..CyberSource2 import (
- _get_processor_exception_html,
- get_signed_purchase_params,
- process_postpay_callback,
- processor_hash,
- render_purchase_form_html
-)
-from ..exceptions import (
- CCProcessorDataException,
- CCProcessorSignatureException,
- CCProcessorWrongAmountException
-)
-from student.tests.factories import UserFactory
-
-
-@ddt.ddt
-class CyberSource2Test(TestCase):
- """
- Test the CyberSource API implementation. As much as possible,
- this test case should use ONLY the public processor interface
- (defined in shoppingcart.processors.__init__.py).
-
- Some of the tests in this suite rely on Django settings
- to be configured a certain way.
-
- """
-
- COST = "10.00"
- CALLBACK_URL = "/test_callback_url"
- FAILED_DECISIONS = ["DECLINE", "CANCEL", "ERROR"]
-
- def setUp(self):
- """ Create a user and an order. """
- super(CyberSource2Test, self).setUp()
-
- self.user = UserFactory()
- self.order = Order.get_cart_for_user(self.user)
- self.order_item = OrderItem.objects.create(
- order=self.order,
- user=self.user,
- unit_cost=self.COST,
- line_cost=self.COST
- )
-
- def assert_dump_recorded(self, order):
- """
- Verify that this order does have a dump of information from the
- payment processor.
- """
- self.assertNotEqual(order.processor_reply_dump, '')
-
- def test_render_purchase_form_html(self):
- # Verify that the HTML form renders with the payment URL specified
- # in the test settings.
- # This does NOT test that all the form parameters are correct;
- # we verify that by testing `get_signed_purchase_params()` directly.
- html = render_purchase_form_html(self.order, callback_url=self.CALLBACK_URL)
- self.assertIn('
- <% courses_url = reverse('courses') %>
- % if receipt_has_donation_item:
- ${_("Thank you for your purchase!")}
- % for inst in instructions:
- ${inst}
- % endfor
- % elif order_type == 'personal':
- ## in case of multiple courses in single self purchase scenario,
- ## we will show the button View Dashboard
- <% dashboard_url = reverse('dashboard') %>
- ${_("View Dashboard")}
-
- ${Text(_(
- u"You have successfully been enrolled for {course_names}. "
- u"The following receipt has been emailed to {receipient_emails}"
- )).format(
- course_names=HTML(u"{course_names}").format(
- course_names=appended_course_names
- ),
- receipient_emails=HTML(u"{receipient_emails}").format(
- receipient_emails=appended_recipient_emails
- ),
- )}
-
- % elif order_type == 'business':
- ${HTML(ungettext(
- u"You have successfully purchased {number} course registration code for {course_names}.",
- u"You have successfully purchased {number} course registration codes for {course_names}.",
- total_registration_codes
- )).format(
- number=total_registration_codes,
- course_names=HTML(u"{course_names}").format(
- course_names=escape(appended_course_names)
- )
- )}
- ${Text(_(u"The following receipt has been emailed to {receipient_emails}")).format(
- receipient_emails=HTML(u"{receipient_emails}").format(
- receipient_emails=appended_recipient_emails,
- )
- )}
- % endif
-
-
-
- % if order_type == 'business':
-
${_("Please send each professional one of these unique registration codes to enroll into the course. The confirmation/receipt email you will receive has an example email template with directions for the individuals enrolling.")}.
-
-
-
${_("Course Name")}
-
${_("Enrollment Code")}
-
${_("Enrollment Link")}
-
${_("Status")}
-
-
- % for reg_code_info in reg_code_info_list:
-
${_('Price per student:')} ${currency_symbol}${"{0:0.2f}".format(item.unit_cost)}
-
- % endif
-
-
-
-
-
- ${item.qty}
-
-
-
- %endif
-
-
-
-
- % endfor
-
-
- % if any_refunds:
-
- ## Translators: Please keep the "" and "" tags around your translation of the word "this" in your translation.
- ${HTML(_("Note: items with strikethough like this have been refunded."))}
-
- % endif
- ${_("Total")}: ${currency_symbol}${"{0:0.2f}".format(order.total_cost)} ${currency.upper()}
-
-
- ## Allow for a theming to be able to insert additional text at the bottom of the page
- <%include file="${static.get_template_path('receipt_custom_pane.html')}" />
-
-
-%block>
diff --git a/lms/templates/shoppingcart/receipt_custom_pane.html b/lms/templates/shoppingcart/receipt_custom_pane.html
deleted file mode 100644
index d4dc149089..0000000000
--- a/lms/templates/shoppingcart/receipt_custom_pane.html
+++ /dev/null
@@ -1,2 +0,0 @@
-## Intentionally left blank. This Mako template can be overriden by a theming template to insert brand
-## specific HTML into the lower pane of the receipt.html page
diff --git a/lms/templates/shoppingcart/registration_code_receipt.html b/lms/templates/shoppingcart/registration_code_receipt.html
deleted file mode 100644
index d1cf6fd44b..0000000000
--- a/lms/templates/shoppingcart/registration_code_receipt.html
+++ /dev/null
@@ -1,92 +0,0 @@
-<%!
-from django.utils.translation import ugettext as _
-from django.urls import reverse
-from six import text_type
-from openedx.core.lib.courses import course_image_url
-from openedx.features.course_experience import course_home_url_name
-%>
-
-<%inherit file="../main.html" />
-<%namespace name='static' file='/static_content.html'/>
-<%block name="pagetitle">${_("Confirm Enrollment")}%block>
-
-<%block name="content">
-
- % if reg_code_already_redeemed:
- ${_("You've clicked a link for an enrollment code that has already been used."
- " Check your {link_start}course dashboard{link_end} to see if you're enrolled in the course,"
- " or contact your company's administrator."
- ).format(
- link_start=u''.format(url=reverse('dashboard')),
- link_end=''
- )}
- % elif redemption_success:
- ${_("You have successfully enrolled in {course_name}."
- " This course has now been added to your dashboard.").format(course_name=course.display_name) | h}
- % elif registered_for_course:
- ${_("You're already enrolled for this course."
- " Visit your {link_start}dashboard{link_end} to see the course."
- ).format(
- link_start=u''.format(url=reverse('dashboard')),
- link_end=''
- )}
- % elif course_full:
- ${_("The course you are enrolling for is full.")}
- % elif enrollment_closed:
- ${_("The course you are enrolling for is closed.")}
- % elif redeem_code_error:
- ${_("There was an error processing your redeem code.")}
- % else:
- ${_("You're about to activate an enrollment code for {course_name} by {site_name}. "
- "This code can only be used one time, so you should only activate this code if you're its intended"
- " recipient.").format(course_name=course.display_name, site_name=site_name) | h}
- % endif
-
-
-
- % if not reg_code_already_redeemed:
- %if redemption_success:
- <% course_url = reverse(course_home_url_name(course.id), args=[text_type(course.id)]) %>
- ${_("View Course")}
- %elif not registered_for_course:
-
- % if reg_code_already_redeemed:
- ${Text(_(
- "You've clicked a link for an enrollment code that has already "
- "been used. Check your {link_start}course dashboard{link_end} "
- "to see if you're enrolled in the course, or contact your "
- "company's administrator."
- )).format(
- link_start=HTML(u'').format(url=reverse('dashboard')),
- link_end=HTML(''),
- )}
- % elif redemption_success:
- ${_(
- "You have successfully enrolled in {course_name}. "
- "This course has now been added to your dashboard."
- ).format(
- course_name=course.display_name,
- )}
- % elif registered_for_course:
- ${Text(_(
- "You're already enrolled for this course. "
- "Visit your {link_start}dashboard{link_end} to see the course."
- )).format(
- link_start=HTML(u'').format(url=reverse('dashboard')),
- link_end=HTML(''),
- )}
- % elif redeem_code_error:
- ${_( "There was an error processing your redeem code.")}
- % else:
- ${_(
- "You're about to activate an enrollment code for {course_name} "
- "by {site_name}. This code can only be used one time, so you "
- "should only activate this code if you're its intended "
- "recipient."
- ).format(
- course_name=course.display_name,
- site_name=site_name,
- )}
- % endif
-
-
-
- % if not reg_code_already_redeemed:
- %if redemption_success:
- ${_("View Dashboard")}
- %elif not registered_for_course:
-
-
-
-
- %endif
- %endif
-
-
-
-%block>
diff --git a/lms/templates/shoppingcart/shopping_cart.html b/lms/templates/shoppingcart/shopping_cart.html
deleted file mode 100644
index 43cc9b9706..0000000000
--- a/lms/templates/shoppingcart/shopping_cart.html
+++ /dev/null
@@ -1,508 +0,0 @@
-<%page expression_filter="h"/>
-<%inherit file="shopping_cart_flow.html" />
-<%block name="review_highlight">class="active"%block>
-
-<%!
-from openedx.core.djangolib.js_utils import js_escaped_string
-from django.urls import reverse
-from edxmako.shortcuts import marketing_link
-from django.utils.translation import ugettext as _
-from openedx.core.djangolib.markup import HTML, Text
-from django.utils.translation import ungettext
-from openedx.core.lib.courses import course_image_url
-%>
-
-
- <%block name="custom_content">
-
- % if is_course_enrollment_closed:
- <%
- ## Translators: course_names is a comma-separated list of one or more course names
- expiry_message = ungettext(
- "{course_names} has been removed because the enrollment period has closed.",
- "{course_names} have been removed because the enrollment period has closed.",
- len(expired_course_names)
- ).format(
- course_names=", ".join(expired_course_names),
- )
- %>
- % endif
-
-
- % if shoppingcart_items:
- <%block name="billing_details_highlight">
- % if order.order_type == 'business':
-
${_('Price per student:')}
-
- ## Translators: currency_symbol is a symbol indicating type of currency, ex "$".
- ## This string would look like this when all variables are in:
- ## "$500.00"
- ${_("{currency_symbol}{price}").format(currency_symbol=currency_symbol, price="{0:0.2f}".format(item.list_price))}
-
-
-
${_('Discount Applied:')}
- ${unit_cost}
-
- % else:
-
${_('Price per student:')}
- ${unit_cost}
-
- % endif
-
-
- % if enable_bulk_purchase:
-
-
-
-
-
-
-
-
-
-
- % endif
-
-
-
-
-
-
-
-
-
-
- % endfor
-
-
-
- % if not discount_applied:
-
-
-
-
-
-
- % else:
-
- ${_('code has been applied')}
-
-
- % endif
- ${_('TOTAL:')}
-
- ## Translators: currency_symbol is a symbol indicating type of currency, ex "$". currency_abbr is
- ## an abbreviation for the currency, ex "USD". This string would look like this when all variables are in:
- ## "$500.00 USD"
- ${_("{currency_symbol}{price} {currency_abbr}").format(currency_symbol=currency_symbol, price="{0:0.2f}".format(amount), currency_abbr=currency.upper())}
-
-
-
-
-
-
- % if order_type == 'business':
-
-
-
- ${_('After this purchase is complete, a receipt is generated with relative billing details and registration codes for students.')}
-
-
-
-
- ${form_html | n, decode.utf8}
-
-
- ${Text(_('After this purchase is complete, {username} will be enrolled in this course.')).format( \
- username=HTML(u' {username}').format(username=order.user.username))}
-
-
- % else:
-
- ${form_html | n, decode.utf8}
-
- ${Text(_('After this purchase is complete, {username} will be enrolled in this course.')).format( \
- username=HTML(u' {username}').format(username=order.user.username))}
-
-
-
-
-
- ${_('After this purchase is complete, a receipt is generated with relative billing details and registration codes for students.')}
-