From d19368525b445a379384faefac2b213556e75070 Mon Sep 17 00:00:00 2001
From: Jeremy Bowman
Date: Wed, 14 Oct 2020 14:24:52 -0400
Subject: [PATCH] DEPR-43 Remove most of the shoppingcart app (#24692)
Removed most of the deprecated shoppingcart app, leaving just enough to allow us to cleanly remove the related database tables later. Also removed the relevant Django settings that weren't in use elsewhere.
---
common/djangoapps/course_modes/models.py | 7 -
.../course_modes/tests/test_views.py | 11 +-
common/djangoapps/util/tests/test_db.py | 3 +
docs/guides/docstrings/lms_index.rst | 1 -
lms/devstack.yml | 13 -
lms/djangoapps/commerce/tests/mocks.py | 20 +-
lms/djangoapps/instructor/enrollment.py | 4 +-
lms/djangoapps/instructor/tests/test_api.py | 1 -
lms/djangoapps/instructor/views/api.py | 4 +-
.../tests/test_tasks_helper.py | 1 -
lms/djangoapps/shoppingcart/admin.py | 183 --
lms/djangoapps/shoppingcart/api.py | 37 -
.../shoppingcart/context_processor.py | 38 -
lms/djangoapps/shoppingcart/decorators.py | 24 -
lms/djangoapps/shoppingcart/exceptions.py | 60 -
.../shoppingcart/management/__init__.py | 0
.../management/commands/__init__.py | 0
.../management/commands/retire_order.py | 48 -
.../shoppingcart/management/tests/__init__.py | 0
.../management/tests/test_retire_order.py | 88 -
lms/djangoapps/shoppingcart/models.py | 2245 -----------------
.../shoppingcart/processors/CyberSource2.py | 725 ------
.../shoppingcart/processors/__init__.py | 91 -
.../shoppingcart/processors/exceptions.py | 31 -
.../shoppingcart/processors/helpers.py | 26 -
.../shoppingcart/processors/tests/__init__.py | 0
.../processors/tests/test_CyberSource2.py | 443 ----
lms/djangoapps/shoppingcart/reports.py | 288 ---
lms/djangoapps/shoppingcart/tests/__init__.py | 0
.../shoppingcart/tests/payment_fake.py | 243 --
.../tests/test_context_processor.py | 84 -
.../shoppingcart/tests/test_models.py | 1042 --------
.../shoppingcart/tests/test_payment_fake.py | 128 -
.../shoppingcart/tests/test_reports.py | 264 --
.../shoppingcart/tests/test_views.py | 1924 --------------
lms/djangoapps/shoppingcart/urls.py | 37 -
lms/djangoapps/shoppingcart/utils.py | 76 -
lms/djangoapps/shoppingcart/views.py | 1040 --------
.../verify_student/tests/test_integration.py | 7 +-
.../verify_student/tests/test_views.py | 68 +-
lms/djangoapps/verify_student/views.py | 15 +-
lms/envs/bok_choy.yml | 5 -
lms/envs/bok_choy_docker.yml | 6 +-
lms/envs/common.py | 27 -
lms/envs/devstack.py | 14 -
lms/envs/devstack_decentralized.py | 14 -
lms/envs/test.py | 23 -
.../views/make_payment_step_view.js | 5 +-
.../dashboard/_dashboard_course_listing.html | 1 -
.../shoppingcart/billing_details.html | 125 -
.../shoppingcart/cybersource_form.html | 9 -
.../shoppingcart/download_report.html | 61 -
lms/templates/shoppingcart/error.html | 11 -
lms/templates/shoppingcart/receipt.html | 369 ---
.../shoppingcart/receipt_custom_pane.html | 2 -
.../registration_code_receipt.html | 92 -
.../registration_code_redemption.html | 100 -
lms/templates/shoppingcart/shopping_cart.html | 508 ----
.../shoppingcart/shopping_cart_flow.html | 29 -
.../shoppingcart/test/fake_payment_error.html | 10 -
.../shoppingcart/test/fake_payment_page.html | 26 -
lms/urls.py | 5 -
requirements/edx/base.in | 1 -
scripts/py2_to_py3_convert_and_create_pr.sh | 75 -
sys_path_hacks/lms/shoppingcart/admin.py | 5 -
sys_path_hacks/lms/shoppingcart/api.py | 5 -
.../lms/shoppingcart/context_processor.py | 5 -
sys_path_hacks/lms/shoppingcart/decorators.py | 5 -
sys_path_hacks/lms/shoppingcart/exceptions.py | 5 -
.../lms/shoppingcart/management/__init__.py | 5 -
.../management/commands/__init__.py | 5 -
.../management/commands/retire_order.py | 5 -
.../shoppingcart/management/tests/__init__.py | 5 -
.../management/tests/test_retire_order.py | 5 -
sys_path_hacks/lms/shoppingcart/models.py | 5 -
.../shoppingcart/processors/CyberSource2.py | 5 -
.../lms/shoppingcart/processors/__init__.py | 5 -
.../lms/shoppingcart/processors/exceptions.py | 5 -
.../lms/shoppingcart/processors/helpers.py | 5 -
.../shoppingcart/processors/tests/__init__.py | 5 -
.../processors/tests/test_CyberSource2.py | 5 -
sys_path_hacks/lms/shoppingcart/reports.py | 5 -
.../lms/shoppingcart/tests/__init__.py | 5 -
.../lms/shoppingcart/tests/payment_fake.py | 5 -
.../tests/test_context_processor.py | 5 -
.../lms/shoppingcart/tests/test_models.py | 5 -
.../shoppingcart/tests/test_payment_fake.py | 5 -
.../lms/shoppingcart/tests/test_reports.py | 5 -
.../lms/shoppingcart/tests/test_views.py | 5 -
sys_path_hacks/lms/shoppingcart/urls.py | 5 -
sys_path_hacks/lms/shoppingcart/utils.py | 5 -
sys_path_hacks/lms/shoppingcart/views.py | 5 -
92 files changed, 79 insertions(+), 10899 deletions(-)
delete mode 100644 lms/djangoapps/shoppingcart/admin.py
delete mode 100644 lms/djangoapps/shoppingcart/api.py
delete mode 100644 lms/djangoapps/shoppingcart/context_processor.py
delete mode 100644 lms/djangoapps/shoppingcart/decorators.py
delete mode 100644 lms/djangoapps/shoppingcart/exceptions.py
delete mode 100644 lms/djangoapps/shoppingcart/management/__init__.py
delete mode 100644 lms/djangoapps/shoppingcart/management/commands/__init__.py
delete mode 100644 lms/djangoapps/shoppingcart/management/commands/retire_order.py
delete mode 100644 lms/djangoapps/shoppingcart/management/tests/__init__.py
delete mode 100644 lms/djangoapps/shoppingcart/management/tests/test_retire_order.py
delete mode 100644 lms/djangoapps/shoppingcart/models.py
delete mode 100644 lms/djangoapps/shoppingcart/processors/CyberSource2.py
delete mode 100644 lms/djangoapps/shoppingcart/processors/__init__.py
delete mode 100644 lms/djangoapps/shoppingcart/processors/exceptions.py
delete mode 100644 lms/djangoapps/shoppingcart/processors/helpers.py
delete mode 100644 lms/djangoapps/shoppingcart/processors/tests/__init__.py
delete mode 100644 lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py
delete mode 100644 lms/djangoapps/shoppingcart/reports.py
delete mode 100644 lms/djangoapps/shoppingcart/tests/__init__.py
delete mode 100644 lms/djangoapps/shoppingcart/tests/payment_fake.py
delete mode 100644 lms/djangoapps/shoppingcart/tests/test_context_processor.py
delete mode 100644 lms/djangoapps/shoppingcart/tests/test_models.py
delete mode 100644 lms/djangoapps/shoppingcart/tests/test_payment_fake.py
delete mode 100644 lms/djangoapps/shoppingcart/tests/test_reports.py
delete mode 100644 lms/djangoapps/shoppingcart/tests/test_views.py
delete mode 100644 lms/djangoapps/shoppingcart/urls.py
delete mode 100644 lms/djangoapps/shoppingcart/utils.py
delete mode 100644 lms/djangoapps/shoppingcart/views.py
delete mode 100644 lms/templates/shoppingcart/billing_details.html
delete mode 100644 lms/templates/shoppingcart/cybersource_form.html
delete mode 100644 lms/templates/shoppingcart/download_report.html
delete mode 100644 lms/templates/shoppingcart/error.html
delete mode 100644 lms/templates/shoppingcart/receipt.html
delete mode 100644 lms/templates/shoppingcart/receipt_custom_pane.html
delete mode 100644 lms/templates/shoppingcart/registration_code_receipt.html
delete mode 100644 lms/templates/shoppingcart/registration_code_redemption.html
delete mode 100644 lms/templates/shoppingcart/shopping_cart.html
delete mode 100644 lms/templates/shoppingcart/shopping_cart_flow.html
delete mode 100644 lms/templates/shoppingcart/test/fake_payment_error.html
delete mode 100644 lms/templates/shoppingcart/test/fake_payment_page.html
delete mode 100644 scripts/py2_to_py3_convert_and_create_pr.sh
delete mode 100644 sys_path_hacks/lms/shoppingcart/admin.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/api.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/context_processor.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/decorators.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/exceptions.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/management/__init__.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/management/commands/__init__.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/management/commands/retire_order.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/management/tests/__init__.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/management/tests/test_retire_order.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/models.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/processors/CyberSource2.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/processors/__init__.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/processors/exceptions.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/processors/helpers.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/processors/tests/__init__.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/processors/tests/test_CyberSource2.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/reports.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/tests/__init__.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/tests/payment_fake.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/tests/test_context_processor.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/tests/test_models.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/tests/test_payment_fake.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/tests/test_reports.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/tests/test_views.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/urls.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/utils.py
delete mode 100644 sys_path_hacks/lms/shoppingcart/views.py
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.')}
-