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('
', html) - self.assertIn('transaction_uuid', html) - self.assertIn('signature', html) - self.assertIn(self.CALLBACK_URL, html) - - def test_get_signed_purchase_params(self): - params = get_signed_purchase_params(self.order, callback_url=self.CALLBACK_URL) - - # Check the callback URL override - self.assertEqual(params['override_custom_receipt_page'], self.CALLBACK_URL) - - # Parameters determined by the order model - self.assertEqual(params['amount'], '10.00') - self.assertEqual(params['currency'], 'usd') - self.assertEqual(params['orderNumber'], u'OrderId: {order_id}'.format(order_id=self.order.id)) - self.assertEqual(params['reference_number'], self.order.id) - - # Parameters determined by the Django (test) settings - self.assertEqual(params['access_key'], '0123456789012345678901') - self.assertEqual(params['profile_id'], 'edx') - - # Some fields will change depending on when the test runs, - # so we just check that they're set to a non-empty string - self.assertGreater(len(params['signed_date_time']), 0) - self.assertGreater(len(params['transaction_uuid']), 0) - - # Constant parameters - self.assertEqual(params['transaction_type'], 'sale') - self.assertEqual(params['locale'], 'en') - self.assertEqual(params['payment_method'], 'card') - self.assertEqual( - params['signed_field_names'], - ",".join([ - 'amount', - 'currency', - 'orderNumber', - 'access_key', - 'profile_id', - 'reference_number', - 'transaction_type', - 'locale', - 'signed_date_time', - 'signed_field_names', - 'unsigned_field_names', - 'transaction_uuid', - 'payment_method', - 'override_custom_receipt_page', - 'override_custom_cancel_page', - ]) - ) - self.assertEqual(params['unsigned_field_names'], '') - - # Check the signature - self.assertEqual(params['signature'], self._signature(params)) - - # We patch the purchased callback because - # we're using the OrderItem base class, which throws an exception - # when item doest not have a course id associated - @patch.object(OrderItem, 'purchased_callback') - def test_process_payment_raises_exception(self, purchased_callback): # pylint: disable=unused-argument - self.order.clear() - OrderItem.objects.create( - order=self.order, - user=self.user, - unit_cost=self.COST, - line_cost=self.COST, - ) - params = self._signed_callback_params(self.order.id, self.COST, self.COST) - process_postpay_callback(params) - - # We patch the purchased callback because - # (a) we're using the OrderItem base class, which doesn't implement this method, and - # (b) we want to verify that the method gets called on success. - @patch.object(OrderItem, 'purchased_callback') - @patch.object(OrderItem, 'pdf_receipt_display_name') - def test_process_payment_success(self, pdf_receipt_display_name, purchased_callback): # pylint: disable=unused-argument - # Simulate a callback from CyberSource indicating that payment was successful - params = self._signed_callback_params(self.order.id, self.COST, self.COST) - result = process_postpay_callback(params) - - # Expect that we processed the payment successfully - self.assertTrue( - result['success'], - msg=u"Payment was not successful: {error}".format(error=result.get('error_html')) - ) - self.assertEqual(result['error_html'], '') - - # Expect that the item's purchased callback was invoked - purchased_callback.assert_called_with() - - # Expect that the order has been marked as purchased - self.assertEqual(result['order'].status, 'purchased') - self.assert_dump_recorded(result['order']) - - def test_process_payment_rejected(self): - # Simulate a callback from CyberSource indicating that the payment was rejected - params = self._signed_callback_params(self.order.id, self.COST, self.COST, decision='REJECT') - result = process_postpay_callback(params) - - # Expect that we get an error message - self.assertFalse(result['success']) - self.assertIn(u"did not accept your payment", result['error_html']) - self.assert_dump_recorded(result['order']) - - def test_process_payment_invalid_signature(self): - # Simulate a callback from CyberSource indicating that the payment was rejected - params = self._signed_callback_params(self.order.id, self.COST, self.COST, signature="invalid!") - result = process_postpay_callback(params) - - # Expect that we get an error message - self.assertFalse(result['success']) - self.assertIn(u"corrupted message regarding your charge", result['error_html']) - - def test_process_payment_invalid_order(self): - # Use an invalid order ID - params = self._signed_callback_params("98272", self.COST, self.COST) - result = process_postpay_callback(params) - - # Expect an error - self.assertFalse(result['success']) - self.assertIn(u"inconsistent data", result['error_html']) - - def test_process_invalid_payment_amount(self): - # Change the payment amount (no longer matches the database order record) - params = self._signed_callback_params(self.order.id, "145.00", "145.00") - result = process_postpay_callback(params) - - # Expect an error - self.assertFalse(result['success']) - self.assertIn(u"different amount than the order total", result['error_html']) - # refresh data for current order - order = Order.objects.get(id=self.order.id) - self.assert_dump_recorded(order) - - def test_process_amount_paid_not_decimal(self): - # Change the payment amount to a non-decimal - params = self._signed_callback_params(self.order.id, self.COST, "abcd") - result = process_postpay_callback(params) - - # Expect an error - self.assertFalse(result['success']) - self.assertIn(u"badly-typed value", result['error_html']) - - def test_process_user_cancelled(self): - # Change the payment amount to a non-decimal - params = self._signed_callback_params(self.order.id, self.COST, "abcd") - params['decision'] = u'CANCEL' - result = process_postpay_callback(params) - - # Expect an error - self.assertFalse(result['success']) - self.assertIn(u"you have cancelled this transaction", result['error_html']) - - @patch.object(OrderItem, 'purchased_callback') - @patch.object(OrderItem, 'pdf_receipt_display_name') - def test_process_no_credit_card_digits(self, pdf_receipt_display_name, purchased_callback): # pylint: disable=unused-argument - # Use a credit card number with no digits provided - params = self._signed_callback_params( - self.order.id, self.COST, self.COST, - card_number='nodigits' - ) - result = process_postpay_callback(params) - - # Expect that we processed the payment successfully - self.assertTrue( - result['success'], - msg=u"Payment was not successful: {error}".format(error=result.get('error_html')) - ) - self.assertEqual(result['error_html'], '') - self.assert_dump_recorded(result['order']) - - # Expect that the order has placeholders for the missing credit card digits - self.assertEqual(result['order'].bill_to_ccnum, '####') - - @ddt.data('req_reference_number', 'req_currency', 'decision', 'auth_amount') - def test_process_missing_parameters(self, missing_param): - # Remove a required parameter - params = self._signed_callback_params(self.order.id, self.COST, self.COST) - del params[missing_param] - - # Recalculate the signature with no signed fields so we can get past - # signature validation. - params['signed_field_names'] = 'reason_code,message' - params['signature'] = self._signature(params) - - result = process_postpay_callback(params) - - # Expect an error - self.assertFalse(result['success']) - self.assertIn(u"did not return a required parameter", result['error_html']) - - @patch.object(OrderItem, 'purchased_callback') - @patch.object(OrderItem, 'pdf_receipt_display_name') - def test_sign_then_verify_unicode(self, pdf_receipt_display_name, purchased_callback): # pylint: disable=unused-argument - params = self._signed_callback_params( - self.order.id, self.COST, self.COST, - first_name=u'\u2699' - ) - - # Verify that this executes without a unicode error - result = process_postpay_callback(params) - self.assertTrue(result['success']) - self.assert_dump_recorded(result['order']) - - @ddt.data('string', u'üñîçø∂é') - def test_get_processor_exception_html(self, error_string): - """ - Tests the processor exception html message - """ - for exception_type in [CCProcessorSignatureException, CCProcessorWrongAmountException, CCProcessorDataException]: - error_msg = error_string - exception = exception_type(error_msg) - html = _get_processor_exception_html(exception) - self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) - self.assertIn('Sorry!', html) - self.assertIn(error_msg, html) - - def _signed_callback_params( - self, order_id, order_amount, paid_amount, - decision='ACCEPT', signature=None, card_number='xxxxxxxxxxxx1111', - first_name='John' - ): - """ - Construct parameters that could be returned from CyberSource - to our payment callback. - - Some values can be overridden to simulate different test scenarios, - but most are fake values captured from interactions with - a CyberSource test account. - - Args: - order_id (string or int): The ID of the `Order` model. - order_amount (string): The cost of the order. - paid_amount (string): The amount the user paid using CyberSource. - - Keyword Args: - - decision (string): Whether the payment was accepted or rejected or declined. - signature (string): If provided, use this value instead of calculating the signature. - card_numer (string): If provided, use this value instead of the default credit card number. - first_name (string): If provided, the first name of the user. - - Returns: - dict - - """ - # Parameters sent from CyberSource to our callback implementation - # These were captured from the CC test server. - - signed_field_names = ["transaction_id", - "decision", - "req_access_key", - "req_profile_id", - "req_transaction_uuid", - "req_transaction_type", - "req_reference_number", - "req_amount", - "req_currency", - "req_locale", - "req_payment_method", - "req_override_custom_receipt_page", - "req_bill_to_forename", - "req_bill_to_surname", - "req_bill_to_email", - "req_bill_to_address_line1", - "req_bill_to_address_city", - "req_bill_to_address_state", - "req_bill_to_address_country", - "req_bill_to_address_postal_code", - "req_card_number", - "req_card_type", - "req_card_expiry_date", - "message", - "reason_code", - "auth_avs_code", - "auth_avs_code_raw", - "auth_response", - "auth_amount", - "auth_code", - "auth_trans_ref_no", - "auth_time", - "bill_trans_ref_no", - "signed_field_names", - "signed_date_time"] - - # if decision is in FAILED_DECISIONS list then remove auth_amount from - # signed_field_names list. - if decision in self.FAILED_DECISIONS: - signed_field_names.remove("auth_amount") - - params = { - # Parameters that change based on the test - "decision": decision, - "req_reference_number": str(order_id), - "req_amount": order_amount, - "auth_amount": paid_amount, - "req_card_number": card_number, - - # Stub values - "utf8": u"✓", - "req_bill_to_address_country": "US", - "auth_avs_code": "X", - "req_card_expiry_date": "01-2018", - "bill_trans_ref_no": "85080648RYI23S6I", - "req_bill_to_address_state": "MA", - "signed_field_names": ",".join(signed_field_names), - "req_payment_method": "card", - "req_transaction_type": "sale", - "auth_code": "888888", - "req_locale": "en", - "reason_code": "100", - "req_bill_to_address_postal_code": "02139", - "req_bill_to_address_line1": "123 Fake Street", - "req_card_type": "001", - "req_bill_to_address_city": "Boston", - "signed_date_time": "2014-08-18T14:07:10Z", - "req_currency": "usd", - "auth_avs_code_raw": "I1", - "transaction_id": "4083708299660176195663", - "auth_time": "2014-08-18T140710Z", - "message": "Request was processed successfully.", - "auth_response": "100", - "req_profile_id": "0000001", - "req_transaction_uuid": "ddd9935b82dd403f9aa4ba6ecf021b1f", - "auth_trans_ref_no": "85080648RYI23S6I", - "req_bill_to_surname": "Doe", - "req_bill_to_forename": first_name, - "req_bill_to_email": "john@example.com", - "req_override_custom_receipt_page": "http://localhost:8000/shoppingcart/postpay_callback/", - "req_access_key": "abcd12345", - } - - # if decision is in FAILED_DECISIONS list then remove the auth_amount from params dict - - if decision in self.FAILED_DECISIONS: - del params["auth_amount"] - - # Calculate the signature - params['signature'] = signature if signature is not None else self._signature(params) - return params - - def _signature(self, params): - """ - Calculate the signature from a dictionary of params. - - NOTE: This method uses the processor's hashing method. That method - is a thin wrapper of standard library calls, and it seemed overly complex - to rewrite that code in the test suite. - - Args: - params (dict): Dictionary with a key 'signed_field_names', - which is a comma-separated list of keys in the dictionary - to include in the signature. - - Returns: - string - - """ - return processor_hash( - ",".join([ - u"{0}={1}".format(signed_field, params[signed_field]) - for signed_field - in params['signed_field_names'].split(u",") - ]) - ) - - def test_process_payment_declined(self): - # Simulate a callback from CyberSource indicating that the payment was declined - params = self._signed_callback_params(self.order.id, self.COST, self.COST, decision='DECLINE') - result = process_postpay_callback(params) - - # Expect that we get an error message - self.assertFalse(result['success']) - self.assertIn(u"payment was declined", result['error_html']) diff --git a/lms/djangoapps/shoppingcart/reports.py b/lms/djangoapps/shoppingcart/reports.py deleted file mode 100644 index 57866190f4..0000000000 --- a/lms/djangoapps/shoppingcart/reports.py +++ /dev/null @@ -1,288 +0,0 @@ -""" Objects and functions related to generating CSV reports """ - - -from decimal import Decimal - -import csv -import unicodecsv -from django.utils.translation import ugettext as _ -import six -from six import text_type - -from course_modes.models import CourseMode -from lms.djangoapps.courseware.courses import get_course_by_id -from shoppingcart.models import CertificateItem, OrderItem -from student.models import CourseEnrollment -from util.query import use_read_replica_if_available -from xmodule.modulestore.django import modulestore - - -class Report(object): - """ - Base class for making CSV reports related to revenue, enrollments, etc - - To make a different type of report, write a new subclass that implements - the methods rows and header. - """ - def __init__(self, start_date, end_date, start_word=None, end_word=None): - self.start_date = start_date - self.end_date = end_date - self.start_word = start_word - self.end_word = end_word - - def rows(self): - """ - Performs database queries necessary for the report and eturns an generator of - lists, in which each list is a separate row of the report. - - Arguments are start_date (datetime), end_date (datetime), start_word (str), - and end_word (str). Date comparisons are start_date <= [date of item] < end_date. - """ - raise NotImplementedError - - def header(self): - """ - Returns the appropriate header based on the report type, in the form of a - list of strings. - """ - raise NotImplementedError - - def write_csv(self, filelike): - """ - Given a file object to write to and {start/end date, start/end letter} bounds, - generates a CSV report of the appropriate type. - """ - items = self.rows() - if six.PY2: - writer = unicodecsv.writer(filelike, encoding="utf-8") - else: - writer = csv.writer(filelike) - writer.writerow(self.header()) - for item in items: - writer.writerow(item) - - -class RefundReport(Report): - """ - Subclass of Report, used to generate Refund Reports for finance purposes. - - For each refund between a given start_date and end_date, we find the relevant - order number, customer name, date of transaction, date of refund, and any service - fees. - """ - def rows(self): - query1 = use_read_replica_if_available( - CertificateItem.objects.select_related('user__profile').filter( - status="refunded", - refund_requested_time__gte=self.start_date, - refund_requested_time__lt=self.end_date, - ).order_by('refund_requested_time')) - query2 = use_read_replica_if_available( - CertificateItem.objects.select_related('user__profile').filter( - status="refunded", - refund_requested_time=None, - )) - - query = query1 | query2 - - for item in query: - yield [ - item.order_id, - item.user.profile.name, - item.fulfilled_time, - item.refund_requested_time, - item.line_cost, - item.service_fee, - ] - - def header(self): - return [ - _("Order Number"), - _("Customer Name"), - _("Date of Original Transaction"), - _("Date of Refund"), - _("Amount of Refund"), - _("Service Fees (if any)"), - ] - - -class ItemizedPurchaseReport(Report): - """ - Subclass of Report, used to generate itemized purchase reports. - - For all purchases (verified certificates, paid course registrations, etc) between - a given start_date and end_date, we find that purchase's time, order ID, status, - quantity, unit cost, total cost, currency, description, and related comments. - """ - def rows(self): - query = use_read_replica_if_available( - OrderItem.objects.filter( - status="purchased", - fulfilled_time__gte=self.start_date, - fulfilled_time__lt=self.end_date, - ).order_by("fulfilled_time")) - - for item in query: - yield [ - item.fulfilled_time, - item.order_id, - item.status, - item.qty, - item.unit_cost, - item.line_cost, - item.currency, - item.line_desc, - item.report_comments, - ] - - def header(self): - return [ - _("Purchase Time"), - _("Order ID"), - _("Status"), - _("Quantity"), - _("Unit Cost"), - _("Total Cost"), - _("Currency"), - _("Description"), - _("Comments") - ] - - -class CertificateStatusReport(Report): - """ - Subclass of Report, used to generate Certificate Status Reports for Ed Services. - - For each course in each university whose name is within the range start_word and end_word, - inclusive, (i.e., the letter range H-J includes both Ithaca College and Harvard University), we - calculate the total enrollment, audit enrollment, honor enrollment, verified enrollment, total - gross revenue, gross revenue over the minimum, and total dollars refunded. - """ - def rows(self): - for course_id in course_ids_between(self.start_word, self.end_word): - # If the first letter of the university is between start_word and end_word, then we include - # it in the report. These comparisons are unicode-safe. - cur_course = get_course_by_id(course_id) - university = cur_course.org - # TODO add term (i.e. Fall 2013) to course? - course = cur_course.number + " " + cur_course.display_name_with_default - counts = CourseEnrollment.objects.enrollment_counts(course_id) - total_enrolled = counts['total'] - audit_enrolled = counts['audit'] - honor_enrolled = counts['honor'] - - if counts['verified'] == 0: - verified_enrolled = 0 - gross_rev = Decimal(0.00) - gross_rev_over_min = Decimal(0.00) - else: - verified_enrolled = counts['verified'] - gross_rev = CertificateItem.verified_certificates_monetary_field_sum(course_id, 'purchased', 'unit_cost') - gross_rev_over_min = gross_rev - (CourseMode.min_course_price_for_verified_for_currency(course_id, 'usd') * verified_enrolled) - - num_verified_over_the_minimum = CertificateItem.verified_certificates_contributing_more_than_minimum(course_id) - - # should I be worried about is_active here? - number_of_refunds = CertificateItem.verified_certificates_count(course_id, 'refunded') - if number_of_refunds == 0: - dollars_refunded = Decimal(0.00) - else: - dollars_refunded = CertificateItem.verified_certificates_monetary_field_sum(course_id, 'refunded', 'unit_cost') - - course_announce_date = "" - course_reg_start_date = "" - course_reg_close_date = "" - registration_period = "" - - yield [ - university, - course, - course_announce_date, - course_reg_start_date, - course_reg_close_date, - registration_period, - total_enrolled, - audit_enrolled, - honor_enrolled, - verified_enrolled, - gross_rev, - gross_rev_over_min, - num_verified_over_the_minimum, - number_of_refunds, - dollars_refunded - ] - - def header(self): - return [ - _("University"), - _("Course"), - _("Course Announce Date"), - _("Course Start Date"), - _("Course Registration Close Date"), - _("Course Registration Period"), - _("Total Enrolled"), - _("Audit Enrollment"), - _("Honor Code Enrollment"), - _("Verified Enrollment"), - _("Gross Revenue"), - _("Gross Revenue over the Minimum"), - _("Number of Verified Students Contributing More than the Minimum"), - _("Number of Refunds"), - _("Dollars Refunded"), - ] - - -class UniversityRevenueShareReport(Report): - """ - Subclass of Report, used to generate University Revenue Share Reports for finance purposes. - - For each course in each university whose name is within the range start_word and end_word, - inclusive, (i.e., the letter range H-J includes both Ithaca College and Harvard University), we calculate - the total revenue generated by that particular course. This includes the number of transactions, - total payments collected, service fees, number of refunds, and total amount of refunds. - """ - def rows(self): - for course_id in course_ids_between(self.start_word, self.end_word): - cur_course = get_course_by_id(course_id) - university = cur_course.org - course = cur_course.number + " " + cur_course.display_name_with_default - total_payments_collected = CertificateItem.verified_certificates_monetary_field_sum(course_id, 'purchased', 'unit_cost') - service_fees = CertificateItem.verified_certificates_monetary_field_sum(course_id, 'purchased', 'service_fee') - num_refunds = CertificateItem.verified_certificates_count(course_id, "refunded") - amount_refunds = CertificateItem.verified_certificates_monetary_field_sum(course_id, 'refunded', 'unit_cost') - num_transactions = (num_refunds * 2) + CertificateItem.verified_certificates_count(course_id, "purchased") - - yield [ - university, - course, - num_transactions, - total_payments_collected, - service_fees, - num_refunds, - amount_refunds - ] - - def header(self): - return [ - _("University"), - _("Course"), - _("Number of Transactions"), - _("Total Payments Collected"), - _("Service Fees (if any)"), - _("Number of Successful Refunds"), - _("Total Amount of Refunds"), - ] - - -def course_ids_between(start_word, end_word): - """ - Returns a list of all valid course_ids that fall alphabetically between start_word and end_word. - These comparisons are unicode-safe. - """ - - valid_courses = [] - for course in modulestore().get_courses(): - course_id = text_type(course.id) - if start_word.lower() <= course_id.lower() <= end_word.lower(): - valid_courses.append(course.id) - return valid_courses diff --git a/lms/djangoapps/shoppingcart/tests/__init__.py b/lms/djangoapps/shoppingcart/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/shoppingcart/tests/payment_fake.py b/lms/djangoapps/shoppingcart/tests/payment_fake.py deleted file mode 100644 index 155da67d31..0000000000 --- a/lms/djangoapps/shoppingcart/tests/payment_fake.py +++ /dev/null @@ -1,243 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Fake payment page for use in acceptance tests. -This view is enabled in the URLs by the feature flag `ENABLE_PAYMENT_FAKE`. - -Note that you will still need to configure this view as the payment -processor endpoint in order for the shopping cart to use it: - - settings.CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" - -You can configure the payment to indicate success or failure by sending a PUT -request to the view with param "success" -set to "success" or "failure". The view defaults to payment success. -""" - - -from django.http import HttpResponse, HttpResponseBadRequest -from django.views.decorators.csrf import csrf_exempt -from django.views.generic.base import View - -from edxmako.shortcuts import render_to_response -# We use the same hashing function as the software under test, -# because it mainly uses standard libraries, and I want -# to avoid duplicating that code. -from shoppingcart.processors.CyberSource2 import processor_hash - - -class PaymentFakeView(View): - """ - Fake payment page for use in acceptance tests. - """ - - # We store the payment status to respond with in a class - # variable. In a multi-process Django app, this wouldn't work, - # since processes don't share memory. Since Lettuce - # runs one Django server process, this works for acceptance testing. - PAYMENT_STATUS_RESPONSE = "success" - - @csrf_exempt - def dispatch(self, *args, **kwargs): - """ - Disable CSRF for these methods. - """ - return super(PaymentFakeView, self).dispatch(*args, **kwargs) - - def post(self, request): - """ - Render a fake payment page. - - This is an HTML form that: - - * Triggers a POST to `postpay_callback()` on submit. - - * Has hidden fields for all the data CyberSource sends to the callback. - - Most of this data is duplicated from the request POST params (e.g. `amount`) - - Other params contain fake data (always the same user name and address. - - Still other params are calculated (signatures) - - * Serves an error page (HTML) with a 200 status code - if the signatures are invalid. This is what CyberSource does. - - Since all the POST requests are triggered by HTML forms, this is - equivalent to the CyberSource payment page, even though it's - served by the shopping cart app. - """ - if self._is_signature_valid(request.POST): - return self._payment_page_response(request.POST) - - else: - return render_to_response('shoppingcart/test/fake_payment_error.html') - - def put(self, request): - """ - Set the status of payment requests to success or failure. - - Accepts one POST param "status" that can be either "success" - or "failure". - """ - new_status = request.body.decode('utf-8') - - if new_status not in ["success", "failure", "decline"]: - return HttpResponseBadRequest() - - else: - # Configure all views to respond with the new status - PaymentFakeView.PAYMENT_STATUS_RESPONSE = new_status - return HttpResponse() - - @staticmethod - def _is_signature_valid(post_params): - """ - Return a bool indicating whether the client sent - us a valid signature in the payment page request. - """ - # Retrieve the list of signed fields - signed_fields = post_params.get('signed_field_names').split(',') - - # Calculate the public signature - hash_val = ",".join([ - "{0}={1}".format(key, post_params[key]) - for key in signed_fields - ]) - public_sig = processor_hash(hash_val) - - return public_sig == post_params.get('signature') - - @classmethod - def response_post_params(cls, post_params): - """ - Calculate the POST params we want to send back to the client. - """ - - if cls.PAYMENT_STATUS_RESPONSE == "success": - decision = "ACCEPT" - elif cls.PAYMENT_STATUS_RESPONSE == "decline": - decision = "DECLINE" - else: - decision = "REJECT" - - resp_params = { - # Indicate whether the payment was successful - "decision": decision, - - # Reflect back parameters we were sent by the client - "req_amount": post_params.get('amount'), - "auth_amount": post_params.get('amount'), - "req_reference_number": post_params.get('reference_number'), - "req_transaction_uuid": post_params.get('transaction_uuid', ''), - "req_access_key": post_params.get('access_key'), - "req_transaction_type": post_params.get('transaction_type'), - "req_override_custom_receipt_page": post_params.get('override_custom_receipt_page', ''), - "req_payment_method": post_params.get('payment_method', ''), - "req_currency": post_params.get('currency'), - "req_locale": post_params.get('locale'), - "signed_date_time": post_params.get('signed_date_time'), - - # Fake data - "req_bill_to_address_city": "Boston", - "req_card_number": "xxxxxxxxxxxx1111", - "req_bill_to_address_state": "MA", - "req_bill_to_address_line1": "123 Fake Street", - "utf8": u"✓", - "reason_code": "100", - "req_card_expiry_date": "01-2018", - "req_bill_to_forename": "John", - "req_bill_to_surname": "Doe", - "auth_code": "888888", - "req_bill_to_address_postal_code": "02139", - "message": "Request was processed successfully.", - "auth_response": "100", - "auth_trans_ref_no": "84997128QYI23CJT", - "auth_time": "2014-08-18T110622Z", - "bill_trans_ref_no": "84997128QYI23CJT", - "auth_avs_code": "X", - "req_bill_to_email": "john@example.com", - "auth_avs_code_raw": "I1", - "req_profile_id": "0000001", - "req_card_type": "001", - "req_bill_to_address_country": "US", - "transaction_id": "4083599817820176195662", - } - - # Indicate which fields we are including in the signature - # Order is important - signed_fields = [ - 'transaction_id', 'decision', 'req_access_key', 'req_profile_id', - 'req_transaction_uuid', 'req_transaction_type', 'req_reference_number', - 'req_amount', 'req_currency', 'req_locale', - 'req_payment_method', 'req_override_custom_receipt_page', - 'req_bill_to_forename', 'req_bill_to_surname', - 'req_bill_to_email', 'req_bill_to_address_line1', - 'req_bill_to_address_city', 'req_bill_to_address_state', - 'req_bill_to_address_country', 'req_bill_to_address_postal_code', - 'req_card_number', 'req_card_type', 'req_card_expiry_date', - 'message', 'reason_code', 'auth_avs_code', - 'auth_avs_code_raw', 'auth_response', 'auth_amount', - 'auth_code', 'auth_trans_ref_no', 'auth_time', - 'bill_trans_ref_no', 'signed_field_names', 'signed_date_time' - ] - - # if decision is decline , cancel or error then remove auth_amount from signed_field. - # list and also delete from resp_params dict - - if decision in ["DECLINE", "CANCEL", "ERROR"]: - signed_fields.remove('auth_amount') - del resp_params["auth_amount"] - - # Add the list of signed fields - resp_params['signed_field_names'] = ",".join(signed_fields) - - # Calculate the public signature - hash_val = ",".join([ - "{0}={1}".format(key, resp_params[key]) - for key in signed_fields - ]) - resp_params['signature'] = processor_hash(hash_val) - - return resp_params - - def _payment_page_response(self, post_params): - """ - Render the payment page to a response. This is an HTML form - that triggers a POST request to `callback_url`. - - The POST params are described in the CyberSource documentation: - http://apps.cybersource.com/library/documentation/dev_guides/Secure_Acceptance_WM/Secure_Acceptance_WM.pdf - - To figure out the POST params to send to the callback, - we either: - - 1) Use fake static data (e.g. always send user name "John Doe") - 2) Use the same info we received (e.g. send the same `amount`) - 3) Dynamically calculate signatures using a shared secret - """ - callback_url = post_params.get('override_custom_receipt_page', '/shoppingcart/postpay_callback/') - - # Build the context dict used to render the HTML form, - # filling in values for the hidden input fields. - # These will be sent in the POST request to the callback URL. - - post_params_success = self.response_post_params(post_params) - - # Build the context dict for decline form, - # remove the auth_amount value from here to - # reproduce exact response coming from actual postback call - - post_params_decline = self.response_post_params(post_params) - del post_params_decline["auth_amount"] - post_params_decline["decision"] = 'DECLINE' - - context_dict = { - - # URL to send the POST request to - "callback_url": callback_url, - - # POST params embedded in the HTML success form - 'post_params_success': post_params_success, - - # POST params embedded in the HTML decline form - 'post_params_decline': post_params_decline - } - - return render_to_response('shoppingcart/test/fake_payment_page.html', context_dict) diff --git a/lms/djangoapps/shoppingcart/tests/test_context_processor.py b/lms/djangoapps/shoppingcart/tests/test_context_processor.py deleted file mode 100644 index 605d7aec4c..0000000000 --- a/lms/djangoapps/shoppingcart/tests/test_context_processor.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Unit tests for shoppingcart context_processor -""" - - -from django.conf import settings -from django.contrib.auth.models import AnonymousUser -from mock import Mock, patch - -from course_modes.tests.factories import CourseModeFactory -from shoppingcart.context_processor import user_has_cart_context_processor -from shoppingcart.models import Order, PaidCourseRegistration -from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory - - -class UserCartContextProcessorUnitTest(ModuleStoreTestCase): - """ - Unit test for shoppingcart context_processor - """ - - def setUp(self): - super(UserCartContextProcessorUnitTest, self).setUp() - - self.user = UserFactory.create() - self.request = Mock() - - def add_to_cart(self): - """ - Adds content to self.user's cart - """ - course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') - CourseModeFactory.create(course_id=course.id) - cart = Order.get_cart_for_user(self.user) - PaidCourseRegistration.add_to_order(cart, course.id) - - @patch.dict(settings.FEATURES, {'ENABLE_SHOPPING_CART': False, 'ENABLE_PAID_COURSE_REGISTRATION': True}) - def test_no_enable_shoppingcart(self): - """ - Tests when FEATURES['ENABLE_SHOPPING_CART'] is not set - """ - self.add_to_cart() - self.request.user = self.user - context = user_has_cart_context_processor(self.request) - self.assertFalse(context['should_display_shopping_cart_func']()) - - @patch.dict(settings.FEATURES, {'ENABLE_SHOPPING_CART': True, 'ENABLE_PAID_COURSE_REGISTRATION': False}) - def test_no_enable_paid_course_registration(self): - """ - Tests when FEATURES['ENABLE_PAID_COURSE_REGISTRATION'] is not set - """ - self.add_to_cart() - self.request.user = self.user - context = user_has_cart_context_processor(self.request) - self.assertFalse(context['should_display_shopping_cart_func']()) - - @patch.dict(settings.FEATURES, {'ENABLE_SHOPPING_CART': True, 'ENABLE_PAID_COURSE_REGISTRATION': True}) - def test_anonymous_user(self): - """ - Tests when request.user is anonymous - """ - self.request.user = AnonymousUser() - context = user_has_cart_context_processor(self.request) - self.assertFalse(context['should_display_shopping_cart_func']()) - - @patch.dict(settings.FEATURES, {'ENABLE_SHOPPING_CART': True, 'ENABLE_PAID_COURSE_REGISTRATION': True}) - def test_no_items_in_cart(self): - """ - Tests when request.user doesn't have a cart with items - """ - self.request.user = self.user - context = user_has_cart_context_processor(self.request) - self.assertFalse(context['should_display_shopping_cart_func']()) - - @patch.dict(settings.FEATURES, {'ENABLE_SHOPPING_CART': True, 'ENABLE_PAID_COURSE_REGISTRATION': True}) - def test_items_in_cart(self): - """ - Tests when request.user has a cart with items - """ - self.add_to_cart() - self.request.user = self.user - context = user_has_cart_context_processor(self.request) - self.assertTrue(context['should_display_shopping_cart_func']()) diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py deleted file mode 100644 index 6de8006bac..0000000000 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ /dev/null @@ -1,1042 +0,0 @@ -""" -Tests for the Shopping Cart Models -""" - - -import copy -import datetime -import json -import smtplib -import sys -from decimal import Decimal - -import ddt -import pytz -import six -from six.moves import range -from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors -from django.conf import settings -from django.contrib.auth.models import AnonymousUser -from django.core import mail -from django.core.mail.message import EmailMessage -from django.db import DatabaseError -from django.test import TestCase -from django.test.utils import override_settings -from django.urls import reverse -from mock import MagicMock, Mock, patch -from opaque_keys.edx.locator import CourseLocator - -from course_modes.models import CourseMode -from shoppingcart.exceptions import ( - AlreadyEnrolledInCourseException, - CourseDoesNotExistException, - InvalidStatusToRetire, - ItemAlreadyInCartException, - PurchasedCallbackException, - UnexpectedOrderItemStatus -) -from shoppingcart.models import ( - CertificateItem, - Coupon, - CouponRedemption, - CourseRegCodeItem, - CourseRegistrationCode, - CourseRegistrationCodeInvoiceItem, - Donation, - InvalidCartItem, - Invoice, - InvoiceHistory, - InvoiceTransaction, - Order, - OrderItem, - OrderItemSubclassPK, - PaidCourseRegistration, - RegistrationCodeRedemption -) -from student.models import CourseEnrollment -from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory - - -@ddt.ddt -class OrderTest(ModuleStoreTestCase): - """ - Test shopping cart orders (e.g., cart contains various items, - order is taken through various pieces of cart state, etc.) - """ - - def setUp(self): - super(OrderTest, self).setUp() - - self.user = UserFactory.create() - course = CourseFactory.create() - self.course_key = course.id - self.other_course_keys = [] - for __ in range(1, 5): - course_key = CourseFactory.create().id - CourseMode.objects.create( - course_id=course_key, - mode_slug=CourseMode.HONOR, - mode_display_name="Honor" - ) - self.other_course_keys.append(course_key) - self.cost = 40 - - # Add mock tracker for event testing. - patcher = patch('lms.djangoapps.shoppingcart.models.segment') - self.mock_tracker = patcher.start() - self.addCleanup(patcher.stop) - - CourseMode.objects.create( - course_id=self.course_key, - mode_slug=CourseMode.HONOR, - mode_display_name="Honor" - ) - - def test_get_cart_for_user(self): - # create a cart - cart = Order.get_cart_for_user(user=self.user) - # add something to it - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - # should return the same cart - cart2 = Order.get_cart_for_user(user=self.user) - self.assertEqual(cart2.orderitem_set.count(), 1) - - def test_user_cart_has_items(self): - anon = AnonymousUser() - self.assertFalse(Order.user_cart_has_items(anon)) - self.assertFalse(Order.user_cart_has_items(self.user)) - cart = Order.get_cart_for_user(self.user) - item = OrderItem(order=cart, user=self.user) - item.save() - self.assertTrue(Order.user_cart_has_items(self.user)) - self.assertFalse(Order.user_cart_has_items(self.user, [CertificateItem])) - self.assertFalse(Order.user_cart_has_items(self.user, [PaidCourseRegistration])) - - def test_user_cart_has_paid_course_registration_items(self): - cart = Order.get_cart_for_user(self.user) - item = PaidCourseRegistration(order=cart, user=self.user) - item.save() - self.assertTrue(Order.user_cart_has_items(self.user, [PaidCourseRegistration])) - self.assertFalse(Order.user_cart_has_items(self.user, [CertificateItem])) - - def test_user_cart_has_certificate_items(self): - cart = Order.get_cart_for_user(self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - self.assertTrue(Order.user_cart_has_items(self.user, [CertificateItem])) - self.assertFalse(Order.user_cart_has_items(self.user, [PaidCourseRegistration])) - - def test_cart_clear(self): - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - CertificateItem.add_to_order(cart, self.other_course_keys[0], self.cost, 'honor') - self.assertEqual(cart.orderitem_set.count(), 2) - self.assertTrue(cart.has_items()) - cart.clear() - self.assertEqual(cart.orderitem_set.count(), 0) - self.assertFalse(cart.has_items()) - - def test_add_item_to_cart_currency_match(self): - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='eur') - # verify that a new item has been added - self.assertEqual(cart.orderitem_set.count(), 1) - # verify that the cart's currency was updated - self.assertEqual(cart.currency, 'eur') - with self.assertRaises(InvalidCartItem): - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd') - # assert that this item did not get added to the cart - self.assertEqual(cart.orderitem_set.count(), 1) - - def test_total_cost(self): - cart = Order.get_cart_for_user(user=self.user) - # add items to the order - course_costs = [(self.other_course_keys[0], 30), - (self.other_course_keys[1], 40), - (self.other_course_keys[2], 10), - (self.other_course_keys[3], 20)] - for course, cost in course_costs: - CertificateItem.add_to_order(cart, course, cost, 'honor') - self.assertEqual(cart.orderitem_set.count(), len(course_costs)) - self.assertEqual(cart.total_cost, sum(cost for _course, cost in course_costs)) - - def test_start_purchase(self): - # Start the purchase, which will mark the cart as "paying" - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd') - cart.start_purchase() - self.assertEqual(cart.status, 'paying') - for item in cart.orderitem_set.all(): - self.assertEqual(item.status, 'paying') - - # Starting the purchase should be idempotent - cart.start_purchase() - self.assertEqual(cart.status, 'paying') - for item in cart.orderitem_set.all(): - self.assertEqual(item.status, 'paying') - - # If we retrieve the cart for the user, we should get a different order - next_cart = Order.get_cart_for_user(user=self.user) - self.assertNotEqual(cart, next_cart) - self.assertEqual(next_cart.status, 'cart') - - # Complete the first purchase - cart.purchase() - self.assertEqual(cart.status, 'purchased') - for item in cart.orderitem_set.all(): - self.assertEqual(item.status, 'purchased') - - # Starting the purchase again should be a no-op - cart.start_purchase() - self.assertEqual(cart.status, 'purchased') - for item in cart.orderitem_set.all(): - self.assertEqual(item.status, 'purchased') - - def test_retire_order_cart(self): - """Test that an order in cart can successfully be retired""" - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd') - - cart.retire() - self.assertEqual(cart.status, 'defunct-cart') - self.assertEqual(cart.orderitem_set.get().status, 'defunct-cart') - - def test_retire_order_paying(self): - """Test that an order in "paying" can successfully be retired""" - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd') - cart.start_purchase() - - cart.retire() - self.assertEqual(cart.status, 'defunct-paying') - self.assertEqual(cart.orderitem_set.get().status, 'defunct-paying') - - @ddt.data( - ("cart", "paying", UnexpectedOrderItemStatus), - ("purchased", "purchased", InvalidStatusToRetire), - ) - @ddt.unpack - def test_retire_order_error(self, order_status, item_status, exception): - """ - Test error cases for retiring an order: - 1) Order item has a different status than the order - 2) The order's status isn't in "cart" or "paying" - """ - cart = Order.get_cart_for_user(user=self.user) - item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd') - - cart.status = order_status - cart.save() - item.status = item_status - item.save() - - with self.assertRaises(exception): - cart.retire() - - @ddt.data('defunct-paying', 'defunct-cart') - def test_retire_order_already_retired(self, status): - """ - Check that orders that have already been retired noop when the method - is called on them again. - """ - cart = Order.get_cart_for_user(user=self.user) - item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd') - cart.status = item.status = status - cart.save() - item.save() - cart.retire() - self.assertEqual(cart.status, status) - self.assertEqual(item.status, status) - - @override_settings(LMS_SEGMENT_KEY="foobar") - @patch.dict(settings.FEATURES, {'STORE_BILLING_INFO': True}) - def test_purchase(self): - # This test is for testing the subclassing functionality of OrderItem, but in - # order to do this, we end up testing the specific functionality of - # CertificateItem, which is not quite good unit test form. Sorry. - cart = Order.get_cart_for_user(user=self.user) - self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_key)) - item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - # Course enrollment object should be created but still inactive - self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_key)) - # Analytics client pipes output to stderr when using the default client - with patch('sys.stderr', sys.stdout.write): - cart.purchase() - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) - - # Test email sending - self.assertEqual(len(mail.outbox), 1) - self.assertEqual('Order Payment Confirmation', mail.outbox[0].subject) - self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, mail.outbox[0].body) - self.assertIn(six.text_type(cart.total_cost), mail.outbox[0].body) - self.assertIn(item.additional_instruction_text(), mail.outbox[0].body) - - # Verify Google Analytics event fired for purchase - self.mock_tracker.track.assert_called_once_with( - self.user.id, - 'Completed Order', - { - 'orderId': 1, - 'currency': 'usd', - 'total': '40.00', - 'revenue': '40.00', # value for revenue field is same as total. - 'products': [ - { - 'sku': u'CertificateItem.honor', - 'name': six.text_type(self.course_key), - 'category': six.text_type(self.course_key.org), - 'price': '40.00', - 'id': 1, - 'quantity': 1 - } - ] - }, - ) - - def test_purchase_item_failure(self): - # once again, we're testing against the specific implementation of - # CertificateItem - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - with patch('lms.djangoapps.shoppingcart.models.CertificateItem.save', side_effect=DatabaseError): - with self.assertRaises(DatabaseError): - cart.purchase() - # verify that we rolled back the entire transaction - self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_key)) - # verify that e-mail wasn't sent - self.assertEqual(len(mail.outbox), 0) - - def test_purchase_twice(self): - cart = Order.get_cart_for_user(self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - # purchase the cart more than once - cart.purchase() - cart.purchase() - self.assertEqual(len(mail.outbox), 1) - - @patch('lms.djangoapps.shoppingcart.models.log.error') - def test_purchase_item_email_smtp_failure(self, error_logger): - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - with patch('lms.djangoapps.shoppingcart.models.EmailMessage.send', side_effect=smtplib.SMTPException): - cart.purchase() - self.assertTrue(error_logger.called) - - @patch('lms.djangoapps.shoppingcart.models.log.error') - def test_purchase_item_email_boto_failure(self, error_logger): - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - with patch.object(EmailMessage, 'send') as mock_send: - mock_send.side_effect = BotoServerError("status", "reason") - cart.purchase() - self.assertTrue(error_logger.called) - - def purchase_with_data(self, cart): - """ purchase a cart with billing information """ - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - cart.purchase( - first='John', - last='Smith', - street1='11 Cambridge Center', - street2='Suite 101', - city='Cambridge', - state='MA', - postalcode='02412', - country='US', - ccnum='1111', - cardtype='001', - ) - - @patch('lms.djangoapps.shoppingcart.models.render_to_string') - @patch.dict(settings.FEATURES, {'STORE_BILLING_INFO': True}) - def test_billing_info_storage_on(self, render): - cart = Order.get_cart_for_user(self.user) - self.purchase_with_data(cart) - self.assertNotEqual(cart.bill_to_first, '') - self.assertNotEqual(cart.bill_to_last, '') - self.assertNotEqual(cart.bill_to_street1, '') - self.assertNotEqual(cart.bill_to_street2, '') - self.assertNotEqual(cart.bill_to_postalcode, '') - self.assertNotEqual(cart.bill_to_ccnum, '') - self.assertNotEqual(cart.bill_to_cardtype, '') - self.assertNotEqual(cart.bill_to_city, '') - self.assertNotEqual(cart.bill_to_state, '') - self.assertNotEqual(cart.bill_to_country, '') - ((_, context), _) = render.call_args - self.assertTrue(context['has_billing_info']) - - @patch('lms.djangoapps.shoppingcart.models.render_to_string') - @patch.dict(settings.FEATURES, {'STORE_BILLING_INFO': False}) - def test_billing_info_storage_off(self, render): - cart = Order.get_cart_for_user(self.user) - self.purchase_with_data(cart) - self.assertNotEqual(cart.bill_to_first, '') - self.assertNotEqual(cart.bill_to_last, '') - self.assertNotEqual(cart.bill_to_city, '') - self.assertNotEqual(cart.bill_to_state, '') - self.assertNotEqual(cart.bill_to_country, '') - self.assertNotEqual(cart.bill_to_postalcode, '') - # things we expect to be missing when the feature is off - self.assertEqual(cart.bill_to_street1, '') - self.assertEqual(cart.bill_to_street2, '') - self.assertEqual(cart.bill_to_ccnum, '') - self.assertEqual(cart.bill_to_cardtype, '') - ((_, context), _) = render.call_args - self.assertFalse(context['has_billing_info']) - - def test_generate_receipt_instructions_callchain(self): - """ - This tests the generate_receipt_instructions call chain (ie calling the function on the - cart also calls it on items in the cart - """ - mock_gen_inst = MagicMock(return_value=(OrderItemSubclassPK(OrderItem, 1), set([]))) - - cart = Order.get_cart_for_user(self.user) - item = OrderItem(user=self.user, order=cart) - item.save() - self.assertTrue(cart.has_items()) - with patch.object(OrderItem, 'generate_receipt_instructions', mock_gen_inst): - cart.generate_receipt_instructions() - mock_gen_inst.assert_called_with() - - def test_confirmation_email_error(self): - CourseMode.objects.create( - course_id=self.course_key, - mode_slug="verified", - mode_display_name="Verified", - min_price=self.cost - ) - - cart = Order.get_cart_for_user(self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'verified') - - # Simulate an error when sending the confirmation - # email. This should NOT raise an exception. - # If it does, then the implicit view-level - # transaction could cause a roll-back, effectively - # reversing order fulfillment. - with patch.object(mail.message.EmailMessage, 'send') as mock_send: - mock_send.side_effect = Exception("Kaboom!") - cart.purchase() - - # Verify that the purchase completed successfully - self.assertEqual(cart.status, 'purchased') - - # Verify that the user is enrolled as "verified" - mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_key) - self.assertTrue(is_active) - self.assertEqual(mode, 'verified') - - -class OrderItemTest(TestCase): - def setUp(self): - super(OrderItemTest, self).setUp() - - self.user = UserFactory.create() - - def test_order_item_purchased_callback(self): - """ - This tests that calling purchased_callback on the base OrderItem class raises NotImplementedError - """ - item = OrderItem(user=self.user, order=Order.get_cart_for_user(self.user)) - with self.assertRaises(NotImplementedError): - item.purchased_callback() - - def test_order_item_generate_receipt_instructions(self): - """ - This tests that the generate_receipt_instructions call chain and also - that calling it on the base OrderItem class returns an empty list - """ - cart = Order.get_cart_for_user(self.user) - item = OrderItem(user=self.user, order=cart) - item.save() - self.assertTrue(cart.has_items()) - (inst_dict, inst_set) = cart.generate_receipt_instructions() - self.assertDictEqual({item.pk_with_subclass: set([])}, inst_dict) - self.assertEqual(set([]), inst_set) - - def test_is_discounted(self): - """ - This tests the is_discounted property of the OrderItem - """ - cart = Order.get_cart_for_user(self.user) - item = OrderItem(user=self.user, order=cart) - - item.list_price = None - item.unit_cost = 100 - self.assertFalse(item.is_discounted) - - item.list_price = 100 - item.unit_cost = 100 - self.assertFalse(item.is_discounted) - - item.list_price = 100 - item.unit_cost = 90 - self.assertTrue(item.is_discounted) - - def test_get_list_price(self): - """ - This tests the get_list_price() method of the OrderItem - """ - cart = Order.get_cart_for_user(self.user) - item = OrderItem(user=self.user, order=cart) - - item.list_price = None - item.unit_cost = 100 - self.assertEqual(item.get_list_price(), item.unit_cost) - - item.list_price = 200 - item.unit_cost = 100 - self.assertEqual(item.get_list_price(), item.list_price) - - -class CertificateItemTest(ModuleStoreTestCase): - """ - Tests for verifying specific CertificateItem functionality - """ - def setUp(self): - super(CertificateItemTest, self).setUp() - - self.user = UserFactory.create() - self.cost = 40 - course = CourseFactory.create() - self.course_key = course.id - course_mode = CourseMode(course_id=self.course_key, - mode_slug="honor", - mode_display_name="honor cert", - min_price=self.cost) - course_mode.save() - course_mode = CourseMode(course_id=self.course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost) - course_mode.save() - - patcher = patch('student.models.tracker') - self.mock_tracker = patcher.start() - self.addCleanup(patcher.stop) - - analytics_patcher = patch('lms.djangoapps.shoppingcart.models.segment') - self.mock_analytics_tracker = analytics_patcher.start() - self.addCleanup(analytics_patcher.stop) - - def _assert_refund_tracked(self): - """ - Assert that we fired a refund event. - """ - self.mock_analytics_tracker.track.assert_called_with( - self.user.id, - 'Refunded Order', - { - 'orderId': 1, - 'currency': 'usd', - 'total': '40.00', - 'revenue': '40.00', # value for revenue field is same as total. - 'products': [ - { - 'sku': u'CertificateItem.verified', - 'name': six.text_type(self.course_key), - 'category': six.text_type(self.course_key.org), - 'price': '40.00', - 'id': 1, - 'quantity': 1 - } - ] - }, - ) - - def test_existing_enrollment(self): - CourseEnrollment.enroll(self.user, self.course_key) - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'verified') - # verify that we are still enrolled - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) - self.mock_tracker.reset_mock() - cart.purchase() - enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key) - self.assertEqual(enrollment.mode, u'verified') - - def test_single_item_template(self): - cart = Order.get_cart_for_user(user=self.user) - cert_item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'verified') - self.assertEqual(cert_item.single_item_receipt_template, 'shoppingcart/receipt.html') - - cert_item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - self.assertEqual(cert_item.single_item_receipt_template, 'shoppingcart/receipt.html') - - @override_settings(LMS_SEGMENT_KEY="foobar") - @patch.dict(settings.FEATURES, {'STORE_BILLING_INFO': True}) - @patch('lms.djangoapps.course_goals.views.segment.track', Mock(return_value=True)) - @patch('student.models.CourseEnrollment.refund_cutoff_date') - def test_refund_cert_callback_no_expiration(self, cutoff_date): - # When there is no expiration date on a verified mode, the user can always get a refund - cutoff_date.return_value = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) - # need to prevent analytics errors from appearing in stderr - with patch('sys.stderr', sys.stdout.write): - CourseEnrollment.enroll(self.user, self.course_key, 'verified') - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'verified') - cart.purchase() - CourseEnrollment.unenroll(self.user, self.course_key) - - target_certs = CertificateItem.objects.filter(course_id=self.course_key, user_id=self.user, status='refunded', mode='verified') - self.assertTrue(target_certs[0]) - self.assertTrue(target_certs[0].refund_requested_time) - self.assertEqual(target_certs[0].order.status, 'refunded') - self._assert_refund_tracked() - - def test_no_refund_on_cert_callback(self): - # If we explicitly skip refunds, the unenroll action should not modify the purchase. - CourseEnrollment.enroll(self.user, self.course_key, 'verified') - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'verified') - cart.purchase() - - CourseEnrollment.unenroll(self.user, self.course_key, skip_refund=True) - target_certs = CertificateItem.objects.filter( - course_id=self.course_key, - user_id=self.user, - status='purchased', - mode='verified' - ) - self.assertTrue(target_certs[0]) - self.assertFalse(target_certs[0].refund_requested_time) - self.assertEqual(target_certs[0].order.status, 'purchased') - - @override_settings(LMS_SEGMENT_KEY="foobar") - @patch.dict(settings.FEATURES, {'STORE_BILLING_INFO': True}) - @patch('lms.djangoapps.course_goals.views.segment.track', Mock(return_value=True)) - @patch('student.models.CourseEnrollment.refund_cutoff_date') - def test_refund_cert_callback_before_expiration(self, cutoff_date): - # If the expiration date has not yet passed on a verified mode, the user can be refunded - many_days = datetime.timedelta(days=60) - - course = CourseFactory.create() - self.course_key = course.id - course_mode = CourseMode(course_id=self.course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost, - expiration_datetime=(datetime.datetime.now(pytz.utc) + many_days)) - course_mode.save() - - cutoff_date.return_value = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) - # need to prevent analytics errors from appearing in stderr - with patch('sys.stderr', sys.stdout.write): - CourseEnrollment.enroll(self.user, self.course_key, 'verified') - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'verified') - cart.purchase() - CourseEnrollment.unenroll(self.user, self.course_key) - - target_certs = CertificateItem.objects.filter(course_id=self.course_key, user_id=self.user, status='refunded', mode='verified') - self.assertTrue(target_certs[0]) - self.assertTrue(target_certs[0].refund_requested_time) - self.assertEqual(target_certs[0].order.status, 'refunded') - self._assert_refund_tracked() - - @patch('student.models.CourseEnrollment.refund_cutoff_date') - def test_refund_cert_callback_before_expiration_email(self, cutoff_date): - """ Test that refund emails are being sent correctly. """ - course = CourseFactory.create() - course_key = course.id - many_days = datetime.timedelta(days=60) - - course_mode = CourseMode(course_id=course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost, - expiration_datetime=datetime.datetime.now(pytz.utc) + many_days) - course_mode.save() - - CourseEnrollment.enroll(self.user, course_key, 'verified') - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, course_key, self.cost, 'verified') - cart.purchase() - - mail.outbox = [] - cutoff_date.return_value = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) - with patch('lms.djangoapps.shoppingcart.models.log.error') as mock_error_logger: - CourseEnrollment.unenroll(self.user, course_key) - self.assertFalse(mock_error_logger.called) - self.assertEqual(len(mail.outbox), 1) - self.assertEqual('[Refund] User-Requested Refund', mail.outbox[0].subject) - self.assertEqual(settings.PAYMENT_SUPPORT_EMAIL, mail.outbox[0].from_email) - self.assertIn('has requested a refund on Order', mail.outbox[0].body) - - @patch('student.models.CourseEnrollment.refund_cutoff_date') - @patch('lms.djangoapps.shoppingcart.models.log.error') - def test_refund_cert_callback_before_expiration_email_error(self, error_logger, cutoff_date): - # If there's an error sending an email to billing, we need to log this error - many_days = datetime.timedelta(days=60) - - course = CourseFactory.create() - course_key = course.id - - course_mode = CourseMode(course_id=course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost, - expiration_datetime=datetime.datetime.now(pytz.utc) + many_days) - course_mode.save() - - CourseEnrollment.enroll(self.user, course_key, 'verified') - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, course_key, self.cost, 'verified') - cart.purchase() - - cutoff_date.return_value = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) - with patch('lms.djangoapps.shoppingcart.models.send_mail', side_effect=smtplib.SMTPException): - CourseEnrollment.unenroll(self.user, course_key) - self.assertTrue(error_logger.call_args[0][0].startswith('Failed sending email')) - - def test_refund_cert_callback_after_expiration(self): - # If the expiration date has passed, the user cannot get a refund - many_days = datetime.timedelta(days=60) - - course = CourseFactory.create() - course_key = course.id - course_mode = CourseMode(course_id=course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost,) - course_mode.save() - - CourseEnrollment.enroll(self.user, course_key, 'verified') - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, course_key, self.cost, 'verified') - cart.purchase() - - course_mode.expiration_datetime = (datetime.datetime.now(pytz.utc) - many_days) - course_mode.save() - - CourseEnrollment.unenroll(self.user, course_key) - target_certs = CertificateItem.objects.filter(course_id=course_key, user_id=self.user, status='refunded', mode='verified') - self.assertEqual(len(target_certs), 0) - - def test_refund_cert_no_cert_exists(self): - # If there is no paid certificate, the refund callback should return nothing - CourseEnrollment.enroll(self.user, self.course_key, 'verified') - ret_val = CourseEnrollment.unenroll(self.user, self.course_key) - self.assertFalse(ret_val) - - def test_no_id_prof_confirm_email(self): - # Pay for a no-id-professional course - course_mode = CourseMode(course_id=self.course_key, - mode_slug="no-id-professional", - mode_display_name="No Id Professional Cert", - min_price=self.cost) - course_mode.save() - CourseEnrollment.enroll(self.user, self.course_key) - cart = Order.get_cart_for_user(user=self.user) - CertificateItem.add_to_order(cart, self.course_key, self.cost, 'no-id-professional') - # verify that we are still enrolled - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) - self.mock_tracker.reset_mock() - cart.purchase() - enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key) - self.assertEqual(enrollment.mode, u'no-id-professional') - - # Check that the tax-deduction information appears in the confirmation email - self.assertEqual(len(mail.outbox), 1) - email = mail.outbox[0] - self.assertEqual('Order Payment Confirmation', email.subject) - self.assertNotIn("If you haven't verified your identity yet, please start the verification process", email.body) - self.assertIn( - "You can unenroll in the course and receive a full refund for 2 days after the course start date.\n", - email.body - ) - - -class DonationTest(ModuleStoreTestCase): - """Tests for the donation order item type. """ - - COST = Decimal('23.45') - - def setUp(self): - """Create a test user and order. """ - super(DonationTest, self).setUp() - self.user = UserFactory.create() - self.cart = Order.get_cart_for_user(self.user) - - def test_donate_to_org(self): - # No course ID provided, so this is a donation to the entire organization - donation = Donation.add_to_order(self.cart, self.COST) - self._assert_donation( - donation, - donation_type="general", - unit_cost=self.COST, - line_desc=u"Donation for {}".format(settings.PLATFORM_NAME) - ) - - def test_donate_to_course(self): - # Create a test course - course = CourseFactory.create(display_name="Test Course") - - # Donate to the course - donation = Donation.add_to_order(self.cart, self.COST, course_id=course.id) - self._assert_donation( - donation, - donation_type="course", - course_id=course.id, - unit_cost=self.COST, - line_desc=u"Donation for Test Course" - ) - - def test_confirmation_email(self): - # Pay for a donation - Donation.add_to_order(self.cart, self.COST) - self.cart.start_purchase() - self.cart.purchase() - - # Check that the tax-deduction information appears in the confirmation email - self.assertEqual(len(mail.outbox), 1) - email = mail.outbox[0] - self.assertEqual('Order Payment Confirmation', email.subject) - self.assertIn("tax purposes", email.body) - - def test_donate_no_such_course(self): - fake_course_id = CourseLocator(org="edx", course="fake", run="course") - with self.assertRaises(CourseDoesNotExistException): - Donation.add_to_order(self.cart, self.COST, course_id=fake_course_id) - - def _assert_donation(self, donation, donation_type=None, course_id=None, unit_cost=None, line_desc=None): - """Verify the donation fields and that the donation can be purchased. """ - self.assertEqual(donation.order, self.cart) - self.assertEqual(donation.user, self.user) - self.assertEqual(donation.donation_type, donation_type) - self.assertEqual(donation.course_id, course_id) - self.assertEqual(donation.qty, 1) - self.assertEqual(donation.unit_cost, unit_cost) - self.assertEqual(donation.currency, "usd") - self.assertEqual(donation.line_desc, line_desc) - - # Verify that the donation is in the cart - self.assertTrue(self.cart.has_items(item_type=Donation)) - self.assertEqual(self.cart.total_cost, unit_cost) - - # Purchase the item - self.cart.start_purchase() - self.cart.purchase() - - # Verify that the donation is marked as purchased - donation = Donation.objects.get(pk=donation.id) - self.assertEqual(donation.status, "purchased") - - -class InvoiceHistoryTest(TestCase): - """Tests for the InvoiceHistory model. """ - - INVOICE_INFO = { - 'is_valid': True, - 'internal_reference': 'Test Internal Ref Num', - 'customer_reference_number': 'Test Customer Ref Num', - } - - CONTACT_INFO = { - 'company_name': 'Test Company', - 'company_contact_name': 'Test Company Contact Name', - 'company_contact_email': 'test-contact@example.com', - 'recipient_name': 'Test Recipient Name', - 'recipient_email': 'test-recipient@example.com', - 'address_line_1': 'Test Address 1', - 'address_line_2': 'Test Address 2', - 'address_line_3': 'Test Address 3', - 'city': 'Test City', - 'state': 'Test State', - 'zip': '12345', - 'country': 'US', - } - - def setUp(self): - super(InvoiceHistoryTest, self).setUp() - invoice_data = copy.copy(self.INVOICE_INFO) - invoice_data.update(self.CONTACT_INFO) - self.course_key = CourseLocator('edX', 'DemoX', 'Demo_Course') - self.invoice = Invoice.objects.create(total_amount="123.45", course_id=self.course_key, **invoice_data) - self.user = UserFactory.create() - - def test_get_invoice_total_amount(self): - """ - test to check the total amount - of the invoices for the course. - """ - total_amount = Invoice.get_invoice_total_amount_for_course(self.course_key) - self.assertEqual(total_amount, 123.45) - - def test_get_total_amount_of_paid_invoices(self): - """ - Test to check the Invoice Transactions amount. - """ - InvoiceTransaction.objects.create( - invoice=self.invoice, - amount='123.45', - currency='usd', - comments='test comments', - status='completed', - created_by=self.user, - last_modified_by=self.user - ) - total_amount_paid = InvoiceTransaction.get_total_amount_of_paid_course_invoices(self.course_key) - self.assertEqual(float(total_amount_paid), 123.45) - - def test_get_total_amount_of_no_invoices(self): - """ - Test to check the Invoice Transactions amount. - """ - total_amount_paid = InvoiceTransaction.get_total_amount_of_paid_course_invoices(self.course_key) - self.assertEqual(float(total_amount_paid), 0) - - def test_invoice_contact_info_history(self): - self._assert_history_invoice_info( - is_valid=True, - internal_ref=self.INVOICE_INFO['internal_reference'], - customer_ref=self.INVOICE_INFO['customer_reference_number'] - ) - self._assert_history_contact_info(**self.CONTACT_INFO) - self._assert_history_items([]) - self._assert_history_transactions([]) - - def test_invoice_generated_registration_codes(self): - """ - test filter out the registration codes - that were generated via Invoice. - """ - invoice_item = CourseRegistrationCodeInvoiceItem.objects.create( - invoice=self.invoice, - qty=5, - unit_price='123.45', - course_id=self.course_key - ) - for i in range(5): - CourseRegistrationCode.objects.create( - code='testcode{counter}'.format(counter=i), - course_id=self.course_key, - created_by=self.user, - invoice=self.invoice, - invoice_item=invoice_item, - mode_slug='honor' - ) - - registration_codes = CourseRegistrationCode.invoice_generated_registration_codes(self.course_key) - self.assertEqual(registration_codes.count(), 5) - - def test_invoice_history_items(self): - # Create an invoice item - CourseRegistrationCodeInvoiceItem.objects.create( - invoice=self.invoice, - qty=1, - unit_price='123.45', - course_id=self.course_key - ) - self._assert_history_items([{ - 'qty': 1, - 'unit_price': '123.45', - 'currency': 'usd', - 'course_id': six.text_type(self.course_key) - }]) - - # Create a second invoice item - CourseRegistrationCodeInvoiceItem.objects.create( - invoice=self.invoice, - qty=2, - unit_price='456.78', - course_id=self.course_key - ) - self._assert_history_items([ - { - 'qty': 1, - 'unit_price': '123.45', - 'currency': 'usd', - 'course_id': six.text_type(self.course_key) - }, - { - 'qty': 2, - 'unit_price': '456.78', - 'currency': 'usd', - 'course_id': six.text_type(self.course_key) - } - ]) - - def test_invoice_history_transactions(self): - # Create an invoice transaction - first_transaction = InvoiceTransaction.objects.create( - invoice=self.invoice, - amount='123.45', - currency='usd', - comments='test comments', - status='completed', - created_by=self.user, - last_modified_by=self.user - ) - self._assert_history_transactions([{ - 'amount': '123.45', - 'currency': 'usd', - 'comments': 'test comments', - 'status': 'completed', - 'created_by': self.user.username, - 'last_modified_by': self.user.username, - }]) - - # Create a second invoice transaction - second_transaction = InvoiceTransaction.objects.create( - invoice=self.invoice, - amount='456.78', - currency='usd', - comments='test more comments', - status='started', - created_by=self.user, - last_modified_by=self.user - ) - self._assert_history_transactions([ - { - 'amount': '123.45', - 'currency': 'usd', - 'comments': 'test comments', - 'status': 'completed', - 'created_by': self.user.username, - 'last_modified_by': self.user.username, - }, - { - 'amount': '456.78', - 'currency': 'usd', - 'comments': 'test more comments', - 'status': 'started', - 'created_by': self.user.username, - 'last_modified_by': self.user.username, - } - ]) - - # Delete the transactions - first_transaction.delete() - second_transaction.delete() - self._assert_history_transactions([]) - - def _assert_history_invoice_info(self, is_valid=True, customer_ref=None, internal_ref=None): - """Check top-level invoice information in the latest history record. """ - latest = self._latest_history() - self.assertEqual(latest['is_valid'], is_valid) - self.assertEqual(latest['customer_reference'], customer_ref) - self.assertEqual(latest['internal_reference'], internal_ref) - - def _assert_history_contact_info(self, **kwargs): - """Check contact info in the latest history record. """ - contact_info = self._latest_history()['contact_info'] - for key, value in six.iteritems(kwargs): - self.assertEqual(contact_info[key], value) - - def _assert_history_items(self, expected_items): - """Check line item info in the latest history record. """ - items = self._latest_history()['items'] - six.assertCountEqual(self, items, expected_items) - - def _assert_history_transactions(self, expected_transactions): - """Check transactions (payments/refunds) in the latest history record. """ - transactions = self._latest_history()['transactions'] - six.assertCountEqual(self, transactions, expected_transactions) - - def _latest_history(self): - """Retrieve the snapshot from the latest history record. """ - latest = InvoiceHistory.objects.latest() - return json.loads(latest.snapshot) diff --git a/lms/djangoapps/shoppingcart/tests/test_payment_fake.py b/lms/djangoapps/shoppingcart/tests/test_payment_fake.py deleted file mode 100644 index 8ef54ddd05..0000000000 --- a/lms/djangoapps/shoppingcart/tests/test_payment_fake.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Tests for the fake payment page used in acceptance tests. -""" - - -from collections import OrderedDict - -from django.test import TestCase - -from shoppingcart.processors.CyberSource2 import sign, verify_signatures -from shoppingcart.processors.exceptions import CCProcessorSignatureException -from shoppingcart.tests.payment_fake import PaymentFakeView - - -class PaymentFakeViewTest(TestCase): - """ - Test that the fake payment view interacts - correctly with the shopping cart. - """ - - def setUp(self): - super(PaymentFakeViewTest, self).setUp() - - # Reset the view state - PaymentFakeView.PAYMENT_STATUS_RESPONSE = "success" - - self.client_post_params = OrderedDict([ - ('amount', '25.00'), - ('currency', 'usd'), - ('transaction_type', 'sale'), - ('orderNumber', '33'), - ('access_key', '123456789'), - ('merchantID', 'edx'), - ('djch', '012345678912'), - ('orderPage_version', 2), - ('orderPage_serialNumber', '1234567890'), - ('profile_id', "00000001"), - ('reference_number', 10), - ('locale', 'en'), - ('signed_date_time', '2014-08-18T13:59:31Z'), - ]) - - def test_accepts_client_signatures(self): - - # Generate shoppingcart signatures - post_params = sign(self.client_post_params) - - # Simulate a POST request from the payment workflow - # page to the fake payment page. - resp = self.client.post( - '/shoppingcart/payment_fake', dict(post_params) - ) - - # Expect that the response was successful - self.assertEqual(resp.status_code, 200) - - # Expect that we were served the payment page - # (not the error page) - self.assertContains(resp, "Payment Form") - - def test_rejects_invalid_signature(self): - - # Generate shoppingcart signatures - post_params = sign(self.client_post_params) - - # Tamper with the signature - post_params['signature'] = "invalid" - - # Simulate a POST request from the payment workflow - # page to the fake payment page. - resp = self.client.post( - '/shoppingcart/payment_fake', dict(post_params) - ) - - # Expect that we got an error - self.assertContains(resp, "Error") - - def test_sends_valid_signature(self): - - # Generate shoppingcart signatures - post_params = sign(self.client_post_params) - - # Get the POST params that the view would send back to us - resp_params = PaymentFakeView.response_post_params(post_params) - - # Check that the client accepts these - try: - verify_signatures(resp_params) - - except CCProcessorSignatureException: - self.fail("Client rejected signatures.") - - def test_set_payment_status(self): - - # Generate shoppingcart signatures - post_params = sign(self.client_post_params) - # Configure the view to declined payments - resp = self.client.put( - '/shoppingcart/payment_fake', - data="decline", content_type='text/plain' - ) - self.assertEqual(resp.status_code, 200) - - # Check that the decision is "DECLINE" - resp_params = PaymentFakeView.response_post_params(post_params) - self.assertEqual(resp_params.get('decision'), 'DECLINE') - - # Configure the view to fail payments - resp = self.client.put( - '/shoppingcart/payment_fake', - data="failure", content_type='text/plain' - ) - self.assertEqual(resp.status_code, 200) - - # Check that the decision is "REJECT" - resp_params = PaymentFakeView.response_post_params(post_params) - self.assertEqual(resp_params.get('decision'), 'REJECT') - - # Configure the view to accept payments - resp = self.client.put( - '/shoppingcart/payment_fake', - data="success", content_type='text/plain' - ) - self.assertEqual(resp.status_code, 200) - - # Check that the decision is "ACCEPT" - resp_params = PaymentFakeView.response_post_params(post_params) - self.assertEqual(resp_params.get('decision'), 'ACCEPT') diff --git a/lms/djangoapps/shoppingcart/tests/test_reports.py b/lms/djangoapps/shoppingcart/tests/test_reports.py deleted file mode 100644 index 28d9e26d6f..0000000000 --- a/lms/djangoapps/shoppingcart/tests/test_reports.py +++ /dev/null @@ -1,264 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Tests for the Shopping Cart Models -""" - - -import datetime -from textwrap import dedent - -import pytest -import pytz -from django.conf import settings -from mock import patch -import six -from six import StringIO -from six import text_type - -from course_modes.models import CourseMode -from shoppingcart.models import ( - CertificateItem, - CourseRegCodeItemAnnotation, - Order, - PaidCourseRegistration, - PaidCourseRegistrationAnnotation -) -from shoppingcart.views import initialize_report -from student.models import CourseEnrollment -from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory - - -class ReportTypeTests(ModuleStoreTestCase): - """ - Tests for the models used to generate certificate status reports - """ - FIVE_MINS = datetime.timedelta(minutes=5) - - @patch('student.models.CourseEnrollment.refund_cutoff_date') - def setUp(self, cutoff_date): - super(ReportTypeTests, self).setUp() - cutoff_date.return_value = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) - # Need to make a *lot* of users for this one - self.first_verified_user = UserFactory.create(profile__name="John Doe") - self.second_verified_user = UserFactory.create(profile__name="Jane Deer") - self.first_audit_user = UserFactory.create(profile__name="Joe Miller") - self.second_audit_user = UserFactory.create(profile__name="Simon Blackquill") - self.third_audit_user = UserFactory.create(profile__name="Super Mario") - self.honor_user = UserFactory.create(profile__name="Princess Peach") - self.first_refund_user = UserFactory.create(profile__name="King Bowsér") - self.second_refund_user = UserFactory.create(profile__name="Súsan Smith") - - # Two are verified, three are audit, one honor - - self.cost = 40 - self.course = CourseFactory.create(org='MITx', number='999', display_name=u'Robot Super Course') - self.course_key = self.course.id - course_mode = CourseMode(course_id=self.course_key, - mode_slug="honor", - mode_display_name="honor cert", - min_price=self.cost) - course_mode.save() - - course_mode2 = CourseMode(course_id=self.course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost) - course_mode2.save() - - # User 1 & 2 will be verified - self.cart1 = Order.get_cart_for_user(self.first_verified_user) - CertificateItem.add_to_order(self.cart1, self.course_key, self.cost, 'verified') - self.cart1.purchase() - - self.cart2 = Order.get_cart_for_user(self.second_verified_user) - CertificateItem.add_to_order(self.cart2, self.course_key, self.cost, 'verified') - self.cart2.purchase() - - # Users 3, 4, and 5 are audit - CourseEnrollment.enroll(self.first_audit_user, self.course_key, "audit") - CourseEnrollment.enroll(self.second_audit_user, self.course_key, "audit") - CourseEnrollment.enroll(self.third_audit_user, self.course_key, "audit") - - # User 6 is honor - CourseEnrollment.enroll(self.honor_user, self.course_key, "honor") - - self.now = datetime.datetime.now(pytz.UTC) - - # Users 7 & 8 are refunds - self.cart = Order.get_cart_for_user(self.first_refund_user) - CertificateItem.add_to_order(self.cart, self.course_key, self.cost, 'verified') - self.cart.purchase() - CourseEnrollment.unenroll(self.first_refund_user, self.course_key) - - self.cart = Order.get_cart_for_user(self.second_refund_user) - CertificateItem.add_to_order(self.cart, self.course_key, self.cost, 'verified') - self.cart.purchase(self.second_refund_user.username, self.course_key) - CourseEnrollment.unenroll(self.second_refund_user, self.course_key) - - self.test_time = datetime.datetime.now(pytz.UTC) - - first_refund = CertificateItem.objects.get(id=3) - first_refund.fulfilled_time = self.test_time - first_refund.refund_requested_time = self.test_time - first_refund.save() - - second_refund = CertificateItem.objects.get(id=4) - second_refund.fulfilled_time = self.test_time - second_refund.refund_requested_time = self.test_time - second_refund.save() - - self.CORRECT_REFUND_REPORT_CSV = dedent(u""" - Order Number,Customer Name,Date of Original Transaction,Date of Refund,Amount of Refund,Service Fees (if any) - 3,King Bowsér,{time_str},{time_str},40.00,0.00 - 4,Súsan Smith,{time_str},{time_str},40.00,0.00 - """.format(time_str=str(self.test_time))) - - self.CORRECT_CERT_STATUS_CSV = dedent(""" - University,Course,Course Announce Date,Course Start Date,Course Registration Close Date,Course Registration Period,Total Enrolled,Audit Enrollment,Honor Code Enrollment,Verified Enrollment,Gross Revenue,Gross Revenue over the Minimum,Number of Verified Students Contributing More than the Minimum,Number of Refunds,Dollars Refunded - MITx,999 Robot Super Course,,,,,6,3,1,2,80.00,0.00,0,2,80.00 - """.format(time_str=str(self.test_time))) - - self.CORRECT_UNI_REVENUE_SHARE_CSV = dedent(""" - University,Course,Number of Transactions,Total Payments Collected,Service Fees (if any),Number of Successful Refunds,Total Amount of Refunds - MITx,999 Robot Super Course,6,80.00,0.00,2,80.00 - """.format(time_str=str(self.test_time))) - - def test_refund_report_rows(self): - report = initialize_report("refund_report", self.now - self.FIVE_MINS, self.now + self.FIVE_MINS) - refunded_certs = report.rows() - - # check that we have the right number - self.assertEqual(len(list(refunded_certs)), 2) - - self.assertTrue(CertificateItem.objects.get(user=self.first_refund_user, course_id=self.course_key)) - self.assertTrue(CertificateItem.objects.get(user=self.second_refund_user, course_id=self.course_key)) - - def test_refund_report_purchased_csv(self): - """ - Tests that a generated purchase report CSV is as we expect - """ - report = initialize_report("refund_report", self.now - self.FIVE_MINS, self.now + self.FIVE_MINS) - csv_file = StringIO() - report.write_csv(csv_file) - csv = csv_file.getvalue() - csv_file.close() - # Using excel mode csv, which automatically ends lines with \r\n, so need to convert to \n - self.assertEqual( - csv.replace('\r\n', '\n').strip() if six.PY3 else csv.replace('\r\n', '\n').strip().decode('utf-8'), - self.CORRECT_REFUND_REPORT_CSV.strip() - ) - - @pytest.mark.skip(reason="Fails in django 2.1 and above and the app is deprecated, hence skipping it") - def test_basic_cert_status_csv(self): - report = initialize_report("certificate_status", self.now - self.FIVE_MINS, self.now + self.FIVE_MINS, 'A', 'Z') - csv_file = StringIO() - report.write_csv(csv_file) - csv = csv_file.getvalue() - self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_CERT_STATUS_CSV.strip()) - - @pytest.mark.skip(reason="Fails in django 2.1 and above and the app is deprecated, hence skipping it") - def test_basic_uni_revenue_share_csv(self): - report = initialize_report("university_revenue_share", self.now - self.FIVE_MINS, self.now + self.FIVE_MINS, 'A', 'Z') - csv_file = StringIO() - report.write_csv(csv_file) - csv = csv_file.getvalue() - self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_UNI_REVENUE_SHARE_CSV.strip()) - - -class ItemizedPurchaseReportTest(ModuleStoreTestCase): - """ - Tests for the models used to generate itemized purchase reports - """ - FIVE_MINS = datetime.timedelta(minutes=5) - TEST_ANNOTATION = u'Ba\xfc\u5305' - - def setUp(self): - super(ItemizedPurchaseReportTest, self).setUp() - - self.user = UserFactory.create() - self.cost = 40 - self.course = CourseFactory.create(org='MITx', number='999', display_name=u'Robot Super Course') - self.course_key = self.course.id - course_mode = CourseMode(course_id=self.course_key, - mode_slug="honor", - mode_display_name="honor cert", - min_price=self.cost) - course_mode.save() - course_mode2 = CourseMode(course_id=self.course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost) - course_mode2.save() - self.annotation = PaidCourseRegistrationAnnotation(course_id=self.course_key, annotation=self.TEST_ANNOTATION) - self.annotation.save() - self.course_reg_code_annotation = CourseRegCodeItemAnnotation(course_id=self.course_key, annotation=self.TEST_ANNOTATION) - self.course_reg_code_annotation.save() - self.cart = Order.get_cart_for_user(self.user) - self.reg = PaidCourseRegistration.add_to_order(self.cart, self.course_key, mode_slug=course_mode.mode_slug) - self.cert_item = CertificateItem.add_to_order(self.cart, self.course_key, self.cost, 'verified') - self.cart.purchase() - self.now = datetime.datetime.now(pytz.UTC) - - paid_reg = PaidCourseRegistration.objects.get(course_id=self.course_key, user=self.user) - paid_reg.fulfilled_time = self.now - paid_reg.refund_requested_time = self.now - paid_reg.save() - - cert = CertificateItem.objects.get(course_id=self.course_key, user=self.user) - cert.fulfilled_time = self.now - cert.refund_requested_time = self.now - cert.save() - - self.CORRECT_CSV = dedent((b""" - Purchase Time,Order ID,Status,Quantity,Unit Cost,Total Cost,Currency,Description,Comments - %s,1,purchased,1,40.00,40.00,usd,Registration for Course: Robot Super Course,Ba\xc3\xbc\xe5\x8c\x85 - %s,1,purchased,1,40.00,40.00,usd,verified cert for course Robot Super Course, - """ % (six.b(str(self.now)), six.b(str(self.now)))).decode('utf-8')) - - def test_purchased_items_btw_dates(self): - report = initialize_report("itemized_purchase_report", self.now - self.FIVE_MINS, self.now + self.FIVE_MINS) - purchases = report.rows() - - # since there's not many purchases, just run through the generator to make sure we've got the right number - self.assertEqual(len(list(purchases)), 2) - - report = initialize_report("itemized_purchase_report", self.now + self.FIVE_MINS, self.now + self.FIVE_MINS + self.FIVE_MINS) - no_purchases = report.rows() - self.assertEqual(len(list(no_purchases)), 0) - - def test_purchased_csv(self): - """ - Tests that a generated purchase report CSV is as we expect - """ - report = initialize_report("itemized_purchase_report", self.now - self.FIVE_MINS, self.now + self.FIVE_MINS) - # Note :In this we are using six.StringIO as memory buffer to read/write csv for testing. - # In case of py2 that will be BytesIO so we will need to decode the value before comparison. - csv_file = StringIO() - report.write_csv(csv_file) - csv = csv_file.getvalue() if six.PY3 else csv_file.getvalue().decode('utf-8') - csv_file.close() - # Using excel mode csv, which automatically ends lines with \r\n, so need to convert to \n - self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_CSV.strip()) - - def test_csv_report_no_annotation(self): - """ - Fill in gap in test coverage. csv_report_comments for PaidCourseRegistration instance with no - matching annotation - """ - # delete the matching annotation - self.annotation.delete() - self.assertEqual("", self.reg.csv_report_comments) - - def test_paidcourseregistrationannotation_unicode(self): - """ - Fill in gap in test coverage. __str__ method of PaidCourseRegistrationAnnotation - """ - self.assertEqual(text_type(self.annotation), u'{} : {}'.format(text_type(self.course_key), self.TEST_ANNOTATION)) - - def test_courseregcodeitemannotationannotation_unicode(self): - """ - Fill in gap in test coverage. __str__ method of CourseRegCodeItemAnnotation - """ - self.assertEqual(text_type(self.course_reg_code_annotation), u'{} : {}'.format(text_type(self.course_key), self.TEST_ANNOTATION)) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py deleted file mode 100644 index 9e31e2e838..0000000000 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ /dev/null @@ -1,1924 +0,0 @@ -""" -Tests for Shopping Cart views -""" - - -import json -from collections import OrderedDict -from datetime import datetime, timedelta -from decimal import Decimal - -import ddt -import pytz -import six -from django.conf import settings -from django.contrib.admin.sites import AdminSite -from django.contrib.auth.models import Group, User -from django.contrib.messages.storage.fallback import FallbackStorage -from django.core import mail -from django.core.cache import cache -from django.http import HttpRequest -from django.test import TestCase -from django.test.utils import override_settings -from django.urls import reverse -from freezegun import freeze_time -from mock import Mock, patch -from pytz import UTC -from six import text_type -from six.moves import range -from six.moves.urllib.parse import urlparse - -from common.test.utils import XssTestMixin -from course_modes.models import CourseMode -from course_modes.tests.factories import CourseModeFactory -from lms.djangoapps.courseware.tests.factories import InstructorFactory -from edxmako.shortcuts import render_to_response -from openedx.core.djangoapps.embargo.test_utils import restrict_course -from ..admin import SoftDeleteCouponAdmin -from ..models import ( - CertificateItem, - Coupon, - CouponRedemption, - CourseRegCodeItem, - CourseRegistrationCode, - DonationConfiguration, - Order, - PaidCourseRegistration, - RegistrationCodeRedemption -) -from ..processors import render_purchase_form_html -from ..processors.CyberSource2 import sign -from ..tests.payment_fake import PaymentFakeView -from ..views import _can_download_report, _get_date_from_str, initialize_report -from student.models import CourseEnrollment -from student.roles import CourseSalesAdminRole -from student.tests.factories import AdminFactory, UserFactory -from util.date_utils import get_default_time_display -from util.testing import UrlResetMixin -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory - - -def mock_render_purchase_form_html(*args, **kwargs): - return render_purchase_form_html(*args, **kwargs) - -form_mock = Mock(side_effect=mock_render_purchase_form_html) - - -def mock_render_to_response(*args, **kwargs): - return render_to_response(*args, **kwargs) - -render_mock = Mock(side_effect=mock_render_to_response) - -postpay_mock = Mock() - - -@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) -@ddt.ddt -class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin): - """ - Test shopping cart view under various states - """ - - @classmethod - def setUpClass(cls): - super(ShoppingCartViewsTests, cls).setUpClass() - cls.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') - cls.course_key = cls.course.id - - verified_course = CourseFactory.create(org='org', number='test', display_name='Test Course') - cls.verified_course_key = verified_course.id - - xss_course = CourseFactory.create(org='xssorg', number='test', display_name='') - cls.xss_course_key = xss_course.id - - cls.testing_course = CourseFactory.create(org='edX', number='888', display_name='Testing Super Course') - - def setUp(self): - super(ShoppingCartViewsTests, self).setUp() - - patcher = patch('student.models.tracker') - self.mock_tracker = patcher.start() - self.user = UserFactory.create() - self.user.set_password('password') - self.user.save() - self.instructor = AdminFactory.create() - self.cost = 40 - self.coupon_code = 'abcde' - self.reg_code = 'qwerty' - self.percentage_discount = 10 - self.course_mode = CourseMode( - course_id=self.course_key, - mode_slug=CourseMode.HONOR, - mode_display_name="honor cert", - min_price=self.cost - ) - self.course_mode.save() - - # Saving another testing course mode - self.testing_cost = 20 - self.testing_course_mode = CourseMode( - course_id=self.testing_course.id, - mode_slug=CourseMode.HONOR, - mode_display_name="testing honor cert", - min_price=self.testing_cost - ) - self.testing_course_mode.save() - - # And for the XSS course - CourseMode( - course_id=self.xss_course_key, - mode_slug=CourseMode.HONOR, - mode_display_name="honor cert", - min_price=self.cost - ).save() - - # And the verified course - self.verified_course_mode = CourseMode( - course_id=self.verified_course_key, - mode_slug=CourseMode.HONOR, - mode_display_name="honor cert", - min_price=self.cost - ) - self.verified_course_mode.save() - - self.cart = Order.get_cart_for_user(self.user) - - self.addCleanup(patcher.stop) - - self.now = datetime.now(pytz.UTC) - self.yesterday = self.now - timedelta(days=1) - self.tomorrow = self.now + timedelta(days=1) - - def get_discount(self, cost): - """ - This method simple return the discounted amount - """ - val = Decimal("{0:.2f}".format(Decimal(self.percentage_discount / 100.00) * cost)) - return cost - val - - def add_coupon(self, course_key, is_active, code): - """ - add dummy coupon into models - """ - coupon = Coupon(code=code, description='testing code', course_id=course_key, - percentage_discount=self.percentage_discount, created_by=self.user, is_active=is_active) - coupon.save() - - def add_reg_code(self, course_key, mode_slug=None, is_valid=True): - """ - add dummy registration code into models - """ - if mode_slug is None: - mode_slug = self.course_mode.mode_slug - course_reg_code = CourseRegistrationCode( - code=self.reg_code, course_id=course_key, - created_by=self.user, mode_slug=mode_slug, - is_valid=is_valid - ) - course_reg_code.save() - - def _add_course_mode(self, min_price=50, mode_slug='honor', expiration_date=None): - """ - Adds a course mode to the test course. - """ - return CourseModeFactory( - course_id=self.course.id, - min_price=min_price, - mode_slug=mode_slug, - expiration_date=expiration_date, - ) - - def add_course_to_user_cart(self, course_key): - """ - adding course to user cart - """ - self.login_user() - reg_item = PaidCourseRegistration.add_to_order(self.cart, course_key, mode_slug=self.course_mode.mode_slug) - return reg_item - - def login_user(self): - self.client.login(username=self.user.username, password="password") - - def test_add_course_to_cart_anon(self): - resp = self.client.post(reverse('add_course_to_cart', args=[text_type(self.course_key)])) - self.assertEqual(resp.status_code, 403) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_billing_details(self): - billing_url = reverse('billing_details') - self.login_user() - - # page not found error because order_type is not business - resp = self.client.get(billing_url) - self.assertEqual(resp.status_code, 404) - - #chagne the order_type to business - self.cart.order_type = 'business' - self.cart.save() - resp = self.client.get(billing_url) - self.assertEqual(resp.status_code, 200) - - ((template, context), _) = render_mock.call_args # pylint: disable=unpacking-non-sequence - self.assertEqual(template, 'shoppingcart/billing_details.html') - # check for the default currency in the context - self.assertEqual(context['currency'], 'usd') - self.assertEqual(context['currency_symbol'], '$') - - data = {'company_name': 'Test Company', 'company_contact_name': 'JohnDoe', - 'company_contact_email': 'john@est.com', 'recipient_name': 'Mocker', - 'recipient_email': 'mock@germ.com', 'company_address_line_1': 'DC Street # 1', - 'company_address_line_2': '', - 'company_city': 'DC', 'company_state': 'NY', 'company_zip': '22003', 'company_country': 'US', - 'customer_reference_number': 'PO#23'} - - resp = self.client.post(billing_url, data) - self.assertEqual(resp.status_code, 200) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - @override_settings(PAID_COURSE_REGISTRATION_CURRENCY=['PKR', 'Rs']) - def test_billing_details_with_override_currency_settings(self): - billing_url = reverse('billing_details') - self.login_user() - - #chagne the order_type to business - self.cart.order_type = 'business' - self.cart.save() - resp = self.client.get(billing_url) - self.assertEqual(resp.status_code, 200) - - ((template, context), __) = render_mock.call_args # pylint: disable=unpacking-non-sequence - - self.assertEqual(template, 'shoppingcart/billing_details.html') - # check for the override currency settings in the context - self.assertEqual(context['currency'], 'PKR') - self.assertEqual(context['currency_symbol'], 'Rs') - - def test_same_coupon_code_applied_on_multiple_items_in_the_cart(self): - """ - test to check that that the same coupon code applied on multiple - items in the cart. - """ - - self.login_user() - # add first course to user cart - resp = self.client.post( - reverse('add_course_to_cart', args=[text_type(self.course_key)]) - ) - self.assertEqual(resp.status_code, 200) - # add and apply the coupon code to course in the cart - self.add_coupon(self.course_key, True, self.coupon_code) - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - # now add the same coupon code to the second course(testing_course) - self.add_coupon(self.testing_course.id, True, self.coupon_code) - #now add the second course to cart, the coupon code should be - # applied when adding the second course to the cart - resp = self.client.post( - reverse('add_course_to_cart', args=[text_type(self.testing_course.id)]) - ) - self.assertEqual(resp.status_code, 200) - #now check the user cart and see that the discount has been applied on both the courses - resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - #first course price is 40$ and the second course price is 20$ - # after 10% discount on both the courses the total price will be 18+36 = 54 - self.assertContains(resp, '54.00') - - def test_add_course_to_cart_already_in_cart(self): - PaidCourseRegistration.add_to_order(self.cart, self.course_key) - self.login_user() - resp = self.client.post(reverse('add_course_to_cart', args=[text_type(self.course_key)])) - self.assertContains( - resp, - u'The course {0} is already in your cart.'.format(text_type(self.course_key)), - status_code=400, - ) - - def test_course_discount_invalid_coupon(self): - self.add_coupon(self.course_key, True, self.coupon_code) - self.add_course_to_user_cart(self.course_key) - non_existing_code = "non_existing_code" - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': non_existing_code}) - self.assertContains( - resp, - u"Discount does not exist against code '{0}'.".format(non_existing_code), - status_code=404, - ) - - def test_valid_qty_greater_then_one_and_purchase_type_should_business(self): - qty = 2 - item = self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) - self.assertEqual(resp.status_code, 200) - data = json.loads(resp.content.decode('utf-8')) - self.assertEqual(data['total_cost'], item.unit_cost * qty) - cart = Order.get_cart_for_user(self.user) - self.assertEqual(cart.order_type, 'business') - - def test_in_valid_qty_case(self): - # invalid quantity, Quantity must be between 1 and 1000. - qty = 0 - item = self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) - self.assertContains(resp, "Quantity must be between 1 and 1000.", status_code=400) - - # invalid quantity, Quantity must be an integer. - qty = 'abcde' - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) - self.assertContains(resp, "Quantity must be an integer.", status_code=400) - - # invalid quantity, Quantity is not present in request - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id}) - self.assertContains(resp, "Quantity must be between 1 and 1000.", status_code=400) - - def test_valid_qty_but_item_not_found(self): - qty = 2 - item_id = '-1' - self.login_user() - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item_id, 'qty': qty}) - self.assertEqual(resp.status_code, 404) - self.assertEqual('Order item does not exist.', resp.content.decode('utf-8')) - - # now testing the case if item id not found in request, - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'qty': qty}) - self.assertEqual(resp.status_code, 400) - self.assertEqual('Order item not found in request.', resp.content.decode('utf-8')) - - def test_purchase_type_should_be_personal_when_qty_is_one(self): - qty = 1 - item = self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) - self.assertEqual(resp.status_code, 200) - data = json.loads(resp.content.decode('utf-8')) - self.assertEqual(data['total_cost'], item.unit_cost * 1) - cart = Order.get_cart_for_user(self.user) - self.assertEqual(cart.order_type, 'personal') - - def test_purchase_type_on_removing_item_and_cart_has_item_with_qty_one(self): - qty = 5 - self.add_course_to_user_cart(self.course_key) - item2 = self.add_course_to_user_cart(self.testing_course.id) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item2.id, 'qty': qty}) - self.assertEqual(resp.status_code, 200) - cart = Order.get_cart_for_user(self.user) - cart_items = cart.orderitem_set.all() - test_flag = False - for cartitem in cart_items: - if cartitem.qty == 5: - test_flag = True - resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), {'id': cartitem.id}) - self.assertEqual(resp.status_code, 200) - self.assertTrue(test_flag) - - cart = Order.get_cart_for_user(self.user) - self.assertEqual(cart.order_type, 'personal') - - def test_billing_details_btn_in_cart_when_qty_is_greater_than_one(self): - qty = 5 - item = self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) - self.assertEqual(resp.status_code, 200) - resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - self.assertContains(resp, "Billing Details") - - def test_purchase_type_should_be_personal_when_remove_all_items_from_cart(self): - item1 = self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item1.id, 'qty': 2}) - self.assertEqual(resp.status_code, 200) - - item2 = self.add_course_to_user_cart(self.testing_course.id) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item2.id, 'qty': 5}) - self.assertEqual(resp.status_code, 200) - - cart = Order.get_cart_for_user(self.user) - cart_items = cart.orderitem_set.all() - test_flag = False - for cartitem in cart_items: - test_flag = True - resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), {'id': cartitem.id}) - self.assertEqual(resp.status_code, 200) - self.assertTrue(test_flag) - - cart = Order.get_cart_for_user(self.user) - self.assertEqual(cart.order_type, 'personal') - - def test_use_valid_coupon_code_and_qty_is_greater_than_one(self): - qty = 5 - item = self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) - self.assertEqual(resp.status_code, 200) - data = json.loads(resp.content.decode('utf-8')) - self.assertEqual(data['total_cost'], item.unit_cost * qty) - - # use coupon code - self.add_coupon(self.course_key, True, self.coupon_code) - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - item = self.cart.orderitem_set.all().select_subclasses()[0] - self.assertEqual(item.unit_cost * qty, 180) - - def test_course_discount_invalid_reg_code(self): - self.add_reg_code(self.course_key) - self.add_course_to_user_cart(self.course_key) - non_existing_code = "non_existing_code" - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': non_existing_code}) - self.assertContains( - resp, - u"Discount does not exist against code '{0}'.".format(non_existing_code), - status_code=404, - ) - - def test_course_discount_inactive_coupon(self): - self.add_coupon(self.course_key, False, self.coupon_code) - self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertContains( - resp, - u"Discount does not exist against code '{0}'.".format(self.coupon_code), - status_code=404, - ) - - def test_course_does_not_exist_in_cart_against_valid_coupon(self): - course_key = text_type(self.course_key) + 'testing' - self.add_coupon(course_key, True, self.coupon_code) - self.add_course_to_user_cart(self.course_key) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertContains( - resp, - u"Discount does not exist against code '{0}'.".format(self.coupon_code), - status_code=404, - ) - - def test_inactive_registration_code_returns_error(self): - """ - test to redeem inactive registration code and - it returns an error. - """ - course_key = text_type(self.course_key) - self.add_reg_code(course_key, is_valid=False) - self.add_course_to_user_cart(self.course_key) - - # now apply the inactive registration code - # it will raise an exception - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertContains( - resp, - u"This enrollment code ({enrollment_code}) is no longer valid.".format( - enrollment_code=self.reg_code), - status_code=400, - ) - - def test_course_does_not_exist_in_cart_against_valid_reg_code(self): - course_key = text_type(self.course_key) + 'testing' - self.add_reg_code(course_key) - self.add_course_to_user_cart(self.course_key) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertContains( - resp, - u"Code '{0}' is not valid for any course in the shopping cart.".format(self.reg_code), - status_code=404, - ) - - def test_cart_item_qty_greater_than_1_against_valid_reg_code(self): - course_key = text_type(self.course_key) - self.add_reg_code(course_key) - item = self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': 4}) - self.assertEqual(resp.status_code, 200) - # now update the cart item quantity and then apply the registration code - # it will raise an exception - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertContains( - resp, - "Cart item quantity should not be greater than 1 when applying activation code", - status_code=404, - ) - - @ddt.data(True, False) - def test_reg_code_uses_associated_mode(self, expired_mode): - """Tests the use of reg codes on verified courses, expired or active. """ - course_key = text_type(self.course_key) - expiration_date = self.yesterday if expired_mode else self.tomorrow - self._add_course_mode(mode_slug='verified', expiration_date=expiration_date) - self.add_reg_code(course_key, mode_slug='verified') - self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('register_code_redemption', args=[self.reg_code]), HTTP_HOST='localhost') - self.assertContains(resp, self.course.display_name) - - @ddt.data(True, False) - def test_reg_code_uses_unknown_mode(self, expired_mode): - """Tests the use of reg codes on verified courses, expired or active. """ - course_key = text_type(self.course_key) - expiration_date = self.yesterday if expired_mode else self.tomorrow - self._add_course_mode(mode_slug='verified', expiration_date=expiration_date) - self.add_reg_code(course_key, mode_slug='bananas') - self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('register_code_redemption', args=[self.reg_code]), HTTP_HOST='localhost') - self.assertContains(resp, self.course.display_name) - self.assertContains(resp, "error processing your redeem code") - - def test_course_discount_for_valid_active_coupon_code(self): - - self.add_coupon(self.course_key, True, self.coupon_code) - self.add_course_to_user_cart(self.course_key) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - # unit price should be updated for that course - item = self.cart.orderitem_set.all().select_subclasses()[0] - self.assertEqual(item.unit_cost, self.get_discount(self.cost)) - - # after getting 10 percent discount - self.assertEqual(self.cart.total_cost, self.get_discount(self.cost)) - - # now using the same coupon code against the same order. - # Only one coupon redemption should be allowed per order. - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertContains( - resp, - "Only one coupon redemption is allowed against an order", - status_code=400, - ) - - def test_course_discount_against_two_distinct_coupon_codes(self): - - self.add_coupon(self.course_key, True, self.coupon_code) - self.add_course_to_user_cart(self.course_key) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - # unit price should be updated for that course - item = self.cart.orderitem_set.all().select_subclasses()[0] - self.assertEqual(item.unit_cost, self.get_discount(self.cost)) - - # now using another valid active coupon code. - # Only one coupon redemption should be allowed per order. - self.add_coupon(self.course_key, True, 'abxyz') - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': 'abxyz'}) - self.assertContains( - resp, - "Only one coupon redemption is allowed against an order", - status_code=400, - ) - - def test_same_coupons_code_on_multiple_courses(self): - - # add two same coupon codes on two different courses - self.add_coupon(self.course_key, True, self.coupon_code) - self.add_coupon(self.testing_course.id, True, self.coupon_code) - self.add_course_to_user_cart(self.course_key) - self.add_course_to_user_cart(self.testing_course.id) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - # unit price should be updated for that course - item = self.cart.orderitem_set.all().select_subclasses()[0] - self.assertEqual(item.unit_cost, self.get_discount(self.cost)) - - item = self.cart.orderitem_set.all().select_subclasses()[1] - self.assertEqual(item.unit_cost, self.get_discount(self.testing_cost)) - - def test_soft_delete_coupon(self): - self.add_coupon(self.course_key, True, self.coupon_code) - coupon = Coupon(code='TestCode', description='testing', course_id=self.course_key, - percentage_discount=12, created_by=self.user, is_active=True) - coupon.save() - self.assertEqual(str(coupon), '[Coupon] code: TestCode course: MITx/999/Robot_Super_Course') - admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo') - admin.is_staff = True - get_coupon = Coupon.objects.get(id=1) - request = HttpRequest() - request.user = admin - request.session = 'session' - messages = FallbackStorage(request) - request._messages = messages # pylint: disable=protected-access - coupon_admin = SoftDeleteCouponAdmin(Coupon, AdminSite()) - test_query_set = coupon_admin.get_queryset(request) - test_actions = coupon_admin.get_actions(request) - self.assertIn('really_delete_selected', test_actions['really_delete_selected']) - self.assertEqual(get_coupon.is_active, True) - coupon_admin.really_delete_selected(request, test_query_set) - for coupon in test_query_set: - self.assertEqual(coupon.is_active, False) - coupon_admin.delete_model(request, get_coupon) - self.assertEqual(get_coupon.is_active, False) - - coupon = Coupon(code='TestCode123', description='testing123', course_id=self.course_key, - percentage_discount=22, created_by=self.user, is_active=True) - coupon.save() - test_query_set = coupon_admin.get_queryset(request) - coupon_admin.really_delete_selected(request, test_query_set) - for coupon in test_query_set: - self.assertEqual(coupon.is_active, False) - - def test_course_free_discount_for_valid_active_reg_code(self): - self.add_reg_code(self.course_key) - self.add_course_to_user_cart(self.course_key) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertEqual(resp.status_code, 200) - - redeem_url = reverse('register_code_redemption', args=[self.reg_code]) - response = self.client.get(redeem_url) - self.assertEqual(response.status_code, 200) - # check button text - self.assertContains(response, 'Activate Course Enrollment') - - #now activate the user by enrolling him/her to the course - response = self.client.post(redeem_url) - self.assertEqual(response.status_code, 200) - - # now testing registration code already used scenario, reusing the same code - # the item has been removed when using the registration code for the first time - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertEqual(resp.status_code, 400) - self.assertIn(u"This enrollment code ({enrollment_code}) is not valid.".format( - enrollment_code=self.reg_code - ), resp.content.decode('utf-8')) - - def test_upgrade_from_valid_reg_code(self): - """Use a valid registration code to upgrade from honor to verified mode. """ - # Ensure the course has a verified mode - course_key = text_type(self.course_key) - self._add_course_mode(mode_slug='verified') - self.add_reg_code(course_key, mode_slug='verified') - - # Enroll as honor in the course with the current user. - CourseEnrollment.enroll(self.user, self.course_key, mode=CourseMode.HONOR) - self.login_user() - current_enrollment, __ = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_key) - self.assertEqual('honor', current_enrollment) - - redeem_url = reverse('register_code_redemption', args=[self.reg_code]) - response = self.client.get(redeem_url) - self.assertEqual(response.status_code, 200) - # check button text - self.assertContains(response, 'Activate Course Enrollment') - - #now activate the user by enrolling him/her to the course - response = self.client.post(redeem_url) - self.assertEqual(response.status_code, 200) - - # Once upgraded, should be "verified" - current_enrollment, __ = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_key) - self.assertEqual('verified', current_enrollment) - - @patch('lms.djangoapps.shoppingcart.views.log.debug') - def test_non_existing_coupon_redemption_on_removing_item(self, debug_log): - - reg_item = self.add_course_to_user_cart(self.course_key) - resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), - {'id': reg_item.id}) - debug_log.assert_called_with( - u'Code redemption does not exist for order item id=%s.', - str(reg_item.id) - ) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(self.cart.orderitem_set.count(), 0) - - @patch('lms.djangoapps.shoppingcart.views.log.info') - def test_existing_coupon_redemption_on_removing_item(self, info_log): - - self.add_coupon(self.course_key, True, self.coupon_code) - reg_item = self.add_course_to_user_cart(self.course_key) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), - {'id': reg_item.id}) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(self.cart.orderitem_set.count(), 0) - info_log.assert_called_with( - u'Coupon "%s" redemption entry removed for user "%s" for order item "%s"', - self.coupon_code, - self.user, - str(reg_item.id) - ) - - @patch('lms.djangoapps.shoppingcart.views.log.info') - def test_reset_redemption_for_coupon(self, info_log): - - self.add_coupon(self.course_key, True, self.coupon_code) - reg_item = self.add_course_to_user_cart(self.course_key) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - resp = self.client.post(reverse('shoppingcart.views.reset_code_redemption', args=[])) - - self.assertEqual(resp.status_code, 200) - info_log.assert_called_with( - u'Coupon redemption entry removed for user %s for order %s', - self.user, - reg_item.id - ) - - @patch('lms.djangoapps.shoppingcart.views.log.info') - def test_coupon_discount_for_multiple_courses_in_cart(self, info_log): - - reg_item = self.add_course_to_user_cart(self.course_key) - self.add_coupon(self.course_key, True, self.coupon_code) - cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.assertEqual(self.cart.orderitem_set.count(), 2) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - # unit_cost should be updated for that particular course for which coupon code is registered - items = self.cart.orderitem_set.all().select_subclasses() - for item in items: - if item.id == reg_item.id: - self.assertEqual(item.unit_cost, self.get_discount(self.cost)) - self.assertEqual(item.list_price, self.cost) - elif item.id == cert_item.id: - self.assertEqual(item.list_price, self.cost) - self.assertEqual(item.unit_cost, self.cost) - - # Delete the discounted item, corresponding coupon redemption should - # be removed for that particular discounted item - resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), - {'id': reg_item.id}) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(self.cart.orderitem_set.count(), 1) - info_log.assert_called_with( - 'Coupon "%s" redemption entry removed for user "%s" for order item "%s"', - self.coupon_code, - self.user, - str(reg_item.id) - ) - - @patch('lms.djangoapps.shoppingcart.views.log.info') - def test_delete_certificate_item(self, info_log): - - self.add_course_to_user_cart(self.course_key) - cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.assertEqual(self.cart.orderitem_set.count(), 2) - - # Delete the discounted item, corresponding coupon redemption - # should be removed for that particular discounted item - resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), - {'id': cert_item.id}) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(self.cart.orderitem_set.count(), 1) - info_log.assert_called_with(u"order item %s removed for user %s", str(cert_item.id), self.user) - - @patch('lms.djangoapps.shoppingcart.views.log.info') - def test_remove_coupon_redemption_on_clear_cart(self, info_log): - - reg_item = self.add_course_to_user_cart(self.course_key) - CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.assertEqual(self.cart.orderitem_set.count(), 2) - - self.add_coupon(self.course_key, True, self.coupon_code) - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - resp = self.client.post(reverse('shoppingcart.views.clear_cart', args=[])) - self.assertEqual(resp.status_code, 200) - self.assertEqual(self.cart.orderitem_set.count(), 0) - - info_log.assert_called_with( - u'Coupon redemption entry removed for user %s for order %s', - self.user, - reg_item.id - ) - - def test_add_course_to_cart_already_registered(self): - CourseEnrollment.enroll(self.user, self.course_key) - self.login_user() - resp = self.client.post(reverse('add_course_to_cart', args=[text_type(self.course_key)])) - self.assertContains( - resp, - u'You are already registered in course {0}.'.format(text_type(self.course_key)), - status_code=400, - ) - - def test_add_nonexistent_course_to_cart(self): - self.login_user() - resp = self.client.post(reverse('add_course_to_cart', args=['non/existent/course'])) - self.assertContains(resp, "The course you requested does not exist.", status_code=404) - - def test_add_course_to_cart_success(self): - self.login_user() - reverse('add_course_to_cart', args=[text_type(self.course_key)]) - resp = self.client.post(reverse('add_course_to_cart', args=[text_type(self.course_key)])) - self.assertEqual(resp.status_code, 200) - self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_key)) - - @patch('lms.djangoapps.shoppingcart.views.render_purchase_form_html', form_mock) - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_show_cart(self): - self.login_user() - reg_item = PaidCourseRegistration.add_to_order( - self.cart, - self.course_key, - mode_slug=self.course_mode.mode_slug - ) - cert_item = CertificateItem.add_to_order( - self.cart, - self.verified_course_key, - self.cost, - self.course_mode.mode_slug - ) - resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - self.assertEqual(resp.status_code, 200) - - ((purchase_form_arg_cart,), _) = form_mock.call_args # pylint: disable=unpacking-non-sequence - purchase_form_arg_cart_items = purchase_form_arg_cart.orderitem_set.all().select_subclasses() - self.assertIn(reg_item, purchase_form_arg_cart_items) - self.assertIn(cert_item, purchase_form_arg_cart_items) - self.assertEqual(len(purchase_form_arg_cart_items), 2) - - ((template, context), _) = render_mock.call_args - self.assertEqual(template, 'shoppingcart/shopping_cart.html') - self.assertEqual(len(context['shoppingcart_items']), 2) - self.assertEqual(context['amount'], 80) - self.assertIn("80.00", context['form_html']) - # check for the default currency in the context - self.assertEqual(context['currency'], 'usd') - self.assertEqual(context['currency_symbol'], '$') - - @patch('lms.djangoapps.shoppingcart.views.render_purchase_form_html', form_mock) - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - @override_settings(PAID_COURSE_REGISTRATION_CURRENCY=['PKR', 'Rs']) - def test_show_cart_with_override_currency_settings(self): - self.login_user() - reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_key) - resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - self.assertEqual(resp.status_code, 200) - - ((purchase_form_arg_cart,), _) = form_mock.call_args # pylint: disable=unpacking-non-sequence - purchase_form_arg_cart_items = purchase_form_arg_cart.orderitem_set.all().select_subclasses() - self.assertIn(reg_item, purchase_form_arg_cart_items) - - ((template, context), _) = render_mock.call_args - self.assertEqual(template, 'shoppingcart/shopping_cart.html') - # check for the override currency settings in the context - self.assertEqual(context['currency'], 'PKR') - self.assertEqual(context['currency_symbol'], 'Rs') - - def test_clear_cart(self): - self.login_user() - PaidCourseRegistration.add_to_order(self.cart, self.course_key) - CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.assertEqual(self.cart.orderitem_set.count(), 2) - resp = self.client.post(reverse('shoppingcart.views.clear_cart', args=[])) - self.assertEqual(resp.status_code, 200) - self.assertEqual(self.cart.orderitem_set.count(), 0) - - @patch('lms.djangoapps.shoppingcart.views.log.exception') - def test_remove_item(self, exception_log): - self.login_user() - reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_key) - cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.assertEqual(self.cart.orderitem_set.count(), 2) - resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), - {'id': reg_item.id}) - self.assertEqual(resp.status_code, 200) - self.assertEqual(self.cart.orderitem_set.count(), 1) - self.assertNotIn(reg_item, self.cart.orderitem_set.all().select_subclasses()) - - self.cart.purchase() - resp2 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), - {'id': cert_item.id}) - self.assertEqual(resp2.status_code, 200) - exception_log.assert_called_with( - u'Cannot remove cart OrderItem id=%s. DoesNotExist or item is already purchased', str(cert_item.id) - ) - - resp3 = self.client.post( - reverse('shoppingcart.views.remove_item', args=[]), - {'id': -1} - ) - self.assertEqual(resp3.status_code, 200) - exception_log.assert_called_with( - u'Cannot remove cart OrderItem id=%s. DoesNotExist or item is already purchased', - '-1' - ) - - @patch('lms.djangoapps.shoppingcart.views.process_postpay_callback', postpay_mock) - def test_postpay_callback_success(self): - postpay_mock.return_value = {'success': True, 'order': self.cart} - self.login_user() - resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) - self.assertEqual(resp.status_code, 302) - self.assertEqual(urlparse(resp.__getitem__('location')).path, - reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) - - @patch('lms.djangoapps.shoppingcart.views.process_postpay_callback', postpay_mock) - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_postpay_callback_failure(self): - postpay_mock.return_value = {'success': False, 'order': self.cart, 'error_html': 'ERROR_TEST!!!'} - self.login_user() - resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) - self.assertContains(resp, 'ERROR_TEST!!!') - - ((template, context), _) = render_mock.call_args - self.assertEqual(template, 'shoppingcart/error.html') - self.assertEqual(context['order'], self.cart) - self.assertEqual(context['error_html'], 'ERROR_TEST!!!') - - @ddt.data(0, 1) - def test_show_receipt_json(self, num_items): - # Create the correct number of items in the order - for __ in range(num_items): - CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.cart.purchase() - self.login_user() - url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id]) - resp = self.client.get(url, HTTP_ACCEPT="application/json") - - # Should have gotten a successful response - self.assertEqual(resp.status_code, 200) - - # Parse the response as JSON and check the contents - json_resp = json.loads(resp.content.decode('utf-8')) - self.assertEqual(json_resp.get('currency'), self.cart.currency) - self.assertEqual(json_resp.get('purchase_datetime'), get_default_time_display(self.cart.purchase_time)) - self.assertEqual(json_resp.get('total_cost'), self.cart.total_cost) - self.assertEqual(json_resp.get('status'), "purchased") - self.assertEqual(json_resp.get('billed_to'), { - 'first_name': self.cart.bill_to_first, - 'last_name': self.cart.bill_to_last, - 'street1': self.cart.bill_to_street1, - 'street2': self.cart.bill_to_street2, - 'city': self.cart.bill_to_city, - 'state': self.cart.bill_to_state, - 'postal_code': self.cart.bill_to_postalcode, - 'country': self.cart.bill_to_country - }) - - self.assertEqual(len(json_resp.get('items')), num_items) - for item in json_resp.get('items'): - self.assertEqual(item, { - 'unit_cost': 40, - 'quantity': 1, - 'line_cost': 40, - 'line_desc': u'{} for course Test Course'.format(self.verified_course_mode.mode_display_name), - 'course_key': six.text_type(self.verified_course_key) - }) - - def test_show_receipt_xss(self): - CertificateItem.add_to_order(self.cart, self.xss_course_key, self.cost, 'honor') - self.cart.purchase() - - self.login_user() - url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id]) - resp = self.client.get(url) - self.assert_no_xss(resp, '') - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_reg_code_xss(self): - self.add_reg_code(self.xss_course_key) - - # One courses in user shopping cart - self.add_course_to_user_cart(self.xss_course_key) - self.assertEqual(self.cart.orderitem_set.count(), 1) - - post_response = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertEqual(post_response.status_code, 200) - - redeem_url = reverse('register_code_redemption', args=[self.reg_code]) - redeem_response = self.client.get(redeem_url) - - self.assert_no_xss(redeem_response, '') - - def test_show_receipt_json_multiple_items(self): - # Two different item types - PaidCourseRegistration.add_to_order( - self.cart, - self.course_key, - mode_slug=self.course_mode.mode_slug - ) - CertificateItem.add_to_order( - self.cart, - self.verified_course_key, - self.cost, - self.verified_course_mode.mode_slug - ) - self.cart.purchase() - - self.login_user() - url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id]) - resp = self.client.get(url, HTTP_ACCEPT="application/json") - - # Should have gotten a successful response - self.assertEqual(resp.status_code, 200) - - # Parse the response as JSON and check the contents - json_resp = json.loads(resp.content.decode('utf-8')) - self.assertEqual(json_resp.get('total_cost'), self.cart.total_cost) - - items = json_resp.get('items') - self.assertEqual(len(items), 2) - self.assertEqual(items[0], { - 'unit_cost': 40, - 'quantity': 1, - 'line_cost': 40, - 'line_desc': 'Registration for Course: Robot Super Course', - 'course_key': six.text_type(self.course_key) - }) - self.assertEqual(items[1], { - 'unit_cost': 40, - 'quantity': 1, - 'line_cost': 40, - 'line_desc': u'{} for course Test Course'.format(self.verified_course_mode.mode_display_name), - 'course_key': six.text_type(self.verified_course_key) - }) - - def test_receipt_json_refunded(self): - mock_enrollment = Mock() - mock_enrollment.refundable.side_effect = lambda: True - mock_enrollment.course_id = self.verified_course_key - mock_enrollment.user = self.user - - CourseMode.objects.create( - course_id=self.verified_course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost - ) - - cert = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'verified') - self.cart.purchase() - cert.refund_cert_callback(course_enrollment=mock_enrollment) - - self.login_user() - url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id]) - resp = self.client.get(url, HTTP_ACCEPT="application/json") - self.assertEqual(resp.status_code, 200) - - json_resp = json.loads(resp.content.decode('utf-8')) - self.assertEqual(json_resp.get('status'), 'refunded') - - def test_show_receipt_404s(self): - PaidCourseRegistration.add_to_order(self.cart, self.course_key) - CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.cart.purchase() - - user2 = UserFactory.create() - cart2 = Order.get_cart_for_user(user2) - PaidCourseRegistration.add_to_order(cart2, self.course_key) - cart2.purchase() - - self.login_user() - resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[cart2.id])) - self.assertEqual(resp.status_code, 404) - - resp2 = self.client.get(reverse('shoppingcart.views.show_receipt', args=[1000])) - self.assertEqual(resp2.status_code, 404) - - def test_total_amount_of_purchased_course(self): - self.add_course_to_user_cart(self.course_key) - self.assertEqual(self.cart.orderitem_set.count(), 1) - self.add_coupon(self.course_key, True, self.coupon_code) - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - - # Total amount of a particular course that is purchased by different users - total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course_key) - self.assertEqual(total_amount, 36) - - self.client.login(username=self.instructor.username, password="test") - cart = Order.get_cart_for_user(self.instructor) - PaidCourseRegistration.add_to_order(cart, self.course_key, mode_slug=self.course_mode.mode_slug) - cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - - total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course_key) - self.assertEqual(total_amount, 76) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_show_receipt_success_with_valid_coupon_code(self): - self.add_course_to_user_cart(self.course_key) - self.add_coupon(self.course_key, True, self.coupon_code) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - - resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) - self.assertContains(resp, 'FirstNameTesting123') - self.assertContains(resp, str(self.get_discount(self.cost))) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_reg_code_and_course_registration_scenario(self): - self.add_reg_code(self.course_key) - - # One courses in user shopping cart - self.add_course_to_user_cart(self.course_key) - self.assertEqual(self.cart.orderitem_set.count(), 1) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertEqual(resp.status_code, 200) - - redeem_url = reverse('register_code_redemption', args=[self.reg_code]) - response = self.client.get(redeem_url) - self.assertEqual(response.status_code, 200) - # check button text - self.assertContains(response, 'Activate Course Enrollment') - - #now activate the user by enrolling him/her to the course - response = self.client.post(redeem_url) - self.assertEqual(response.status_code, 200) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_reg_code_with_multiple_courses_and_checkout_scenario(self): - self.add_reg_code(self.course_key) - - # Two courses in user shopping cart - self.login_user() - PaidCourseRegistration.add_to_order(self.cart, self.course_key, mode_slug=self.course_mode.mode_slug) - item2 = PaidCourseRegistration.add_to_order( - self.cart, - self.testing_course.id, - mode_slug=self.course_mode.mode_slug - ) - self.assertEqual(self.cart.orderitem_set.count(), 2) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertEqual(resp.status_code, 200) - - redeem_url = reverse('register_code_redemption', args=[self.reg_code]) - resp = self.client.get(redeem_url) - # check button text - self.assertContains(resp, 'Activate Course Enrollment') - - #now activate the user by enrolling him/her to the course - resp = self.client.post(redeem_url) - self.assertEqual(resp.status_code, 200) - - resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - self.assertContains(resp, 'Payment') - self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - - resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) - self.assertEqual(resp.status_code, 200) - - ((template, context), _) = render_mock.call_args # pylint: disable=unpacking-non-sequence - self.assertEqual(template, 'shoppingcart/receipt.html') - self.assertEqual(context['order'], self.cart) - self.assertEqual(context['order'].total_cost, self.testing_cost) - - course_enrollment = CourseEnrollment.objects.filter(user=self.user) - self.assertEqual(course_enrollment.count(), 2) - - # make sure the enrollment_ids were stored in the PaidCourseRegistration items - # refetch them first since they are updated - # item1 has been deleted from the the cart. - # User has been enrolled for the item1 - item2 = PaidCourseRegistration.objects.get(id=item2.id) - self.assertIsNotNone(item2.course_enrollment) - self.assertEqual(item2.course_enrollment.course_id, self.testing_course.id) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_show_receipt_success_with_valid_reg_code(self): - self.add_course_to_user_cart(self.course_key) - self.add_reg_code(self.course_key) - - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) - self.assertEqual(resp.status_code, 200) - self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - - resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) - self.assertContains(resp, '0.00') - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_show_receipt_success(self): - reg_item = PaidCourseRegistration.add_to_order( - self.cart, - self.course_key, - mode_slug=self.course_mode.mode_slug - ) - cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - - self.login_user() - resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) - self.assertContains(resp, 'FirstNameTesting123') - self.assertContains(resp, '80.00') - - ((template, context), _) = render_mock.call_args # pylint: disable=unpacking-non-sequence - self.assertEqual(template, 'shoppingcart/receipt.html') - self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item, context['shoppingcart_items'][0]) - self.assertIn(cert_item, context['shoppingcart_items'][1]) - self.assertFalse(context['any_refunds']) - # check for the default currency settings in the context - self.assertEqual(context['currency_symbol'], '$') - self.assertEqual(context['currency'], 'usd') - - @override_settings(PAID_COURSE_REGISTRATION_CURRENCY=['PKR', 'Rs']) - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_show_receipt_success_with_override_currency_settings(self): - reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_key) - cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - - self.login_user() - resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) - self.assertEqual(resp.status_code, 200) - - ((template, context), _) = render_mock.call_args # pylint: disable=unpacking-non-sequence - self.assertEqual(template, 'shoppingcart/receipt.html') - self.assertIn(reg_item, context['shoppingcart_items'][0]) - self.assertIn(cert_item, context['shoppingcart_items'][1]) - - # check for the override currency settings in the context - self.assertEqual(context['currency_symbol'], 'Rs') - self.assertEqual(context['currency'], 'PKR') - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_show_receipt_success_with_upgrade(self): - - reg_item = PaidCourseRegistration.add_to_order( - self.cart, - self.course_key, - mode_slug=self.course_mode.mode_slug - ) - cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - - self.login_user() - - self.mock_tracker.emit.reset_mock() - resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) - - self.assertContains(resp, 'FirstNameTesting123') - self.assertContains(resp, '80.00') - - ((template, context), _) = render_mock.call_args - - # When we come from the upgrade flow, we get these context variables - - self.assertEqual(template, 'shoppingcart/receipt.html') - self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item, context['shoppingcart_items'][0]) - self.assertIn(cert_item, context['shoppingcart_items'][1]) - self.assertFalse(context['any_refunds']) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_show_receipt_success_refund(self): - reg_item = PaidCourseRegistration.add_to_order( - self.cart, - self.course_key, - mode_slug=self.course_mode.mode_slug - ) - cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') - self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') - cert_item.status = "refunded" - cert_item.save() - self.assertEqual(self.cart.total_cost, 40) - self.login_user() - resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) - self.assertContains(resp, '40.00') - - ((template, context), _tmp) = render_mock.call_args - self.assertEqual(template, 'shoppingcart/receipt.html') - self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item, context['shoppingcart_items'][0]) - self.assertIn(cert_item, context['shoppingcart_items'][1]) - self.assertTrue(context['any_refunds']) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_show_receipt_success_custom_receipt_page(self): - cert_item = CertificateItem.add_to_order(self.cart, self.course_key, self.cost, 'honor') - self.cart.purchase() - self.login_user() - receipt_url = reverse('shoppingcart.views.show_receipt', args=[self.cart.id]) - resp = self.client.get(receipt_url) - self.assertEqual(resp.status_code, 200) - ((template, _context), _tmp) = render_mock.call_args - self.assertEqual(template, cert_item.single_item_receipt_template) - - def _assert_404(self, url, use_post=False): - """ - Helper method to assert that a given url will return a 404 status code - """ - if use_post: - response = self.client.post(url) - else: - response = self.client.get(url) - self.assertEqual(response.status_code, 404) - - @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': False}) - def test_disabled_paid_courses(self): - """ - Assert that the pages that require ENABLE_PAID_COURSE_REGISTRATION=True return a - HTTP 404 status code when we have this flag turned off - """ - self.login_user() - self._assert_404(reverse('shoppingcart.views.show_cart', args=[])) - self._assert_404(reverse('shoppingcart.views.clear_cart', args=[])) - self._assert_404(reverse('shoppingcart.views.remove_item', args=[]), use_post=True) - self._assert_404(reverse('register_code_redemption', args=["testing"])) - self._assert_404(reverse('shoppingcart.views.use_code', args=[]), use_post=True) - self._assert_404(reverse('shoppingcart.views.update_user_cart', args=[])) - self._assert_404(reverse('shoppingcart.views.reset_code_redemption', args=[]), use_post=True) - self._assert_404(reverse('billing_details', args=[])) - - -class ReceiptRedirectTest(SharedModuleStoreTestCase): - """Test special-case redirect from the receipt page. """ - - COST = 40 - PASSWORD = 'password' - - @classmethod - def setUpClass(cls): - super(ReceiptRedirectTest, cls).setUpClass() - cls.course = CourseFactory.create() - cls.course_key = cls.course.id - - def setUp(self): - super(ReceiptRedirectTest, self).setUp() - self.user = UserFactory.create() - self.user.set_password(self.PASSWORD) - self.user.save() - self.course_mode = CourseMode( - course_id=self.course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.COST - ) - self.course_mode.save() - self.cart = Order.get_cart_for_user(self.user) - self.client.login( - username=self.user.username, - password=self.PASSWORD - ) - - -@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) -class ShoppingcartViewsClosedEnrollment(ModuleStoreTestCase): - """ - Test suite for ShoppingcartViews Course Enrollments Closed or not - """ - def setUp(self): - super(ShoppingcartViewsClosedEnrollment, self).setUp() - self.user = UserFactory.create() - self.user.set_password('password') - self.user.save() - self.instructor = AdminFactory.create() - self.cost = 40 - - self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') - self.course_key = self.course.id - self.course_mode = CourseMode( - course_id=self.course_key, - mode_slug=CourseMode.HONOR, - mode_display_name="honor cert", - min_price=self.cost - ) - self.course_mode.save() - self.testing_course = CourseFactory.create( - org='Edx', - number='999', - display_name='Testing Super Course', - metadata={"invitation_only": False} - ) - self.testing_course_mode = CourseMode( - course_id=self.testing_course.id, - mode_slug=CourseMode.HONOR, - mode_display_name="honor cert", - min_price=self.cost - ) - self.course_mode.save() - self.percentage_discount = 20.0 - self.coupon_code = 'asdsad' - self.course_mode = CourseMode(course_id=self.testing_course.id, - mode_slug="honor", - mode_display_name="honor cert", - min_price=self.cost) - self.course_mode.save() - self.cart = Order.get_cart_for_user(self.user) - self.now = datetime.now(pytz.UTC) - self.tomorrow = self.now + timedelta(days=1) - self.nextday = self.tomorrow + timedelta(days=1) - - def add_coupon(self, course_key, is_active, code): - """ - add dummy coupon into models - """ - coupon = Coupon(code=code, description='testing code', course_id=course_key, - percentage_discount=self.percentage_discount, created_by=self.user, is_active=is_active) - coupon.save() - - def login_user(self): - """ - Helper fn to login self.user - """ - self.client.login(username=self.user.username, password="password") - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_to_check_that_cart_item_enrollment_is_closed(self): - self.login_user() - reg_item1 = PaidCourseRegistration.add_to_order(self.cart, self.course_key) - expired_course_item = PaidCourseRegistration.add_to_order(self.cart, self.testing_course.id) - - # update the testing_course enrollment dates - self.testing_course.enrollment_start = self.tomorrow - self.testing_course.enrollment_end = self.nextday - self.testing_course = self.update_course(self.testing_course, self.user.id) - - # now add the same coupon code to the second course(testing_course) - self.add_coupon(self.testing_course.id, True, self.coupon_code) - resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) - self.assertEqual(resp.status_code, 200) - - coupon_redemption = CouponRedemption.objects.filter(coupon__course_id=expired_course_item.course_id, - order=expired_course_item.order_id) - self.assertEqual(coupon_redemption.count(), 1) - # testing_course enrollment is closed but the course is in the cart - # so we delete that item from the cart and display the message in the cart - # coupon redemption entry should also be deleted when the item is expired. - resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - self.assertEqual(resp.status_code, 200) - self.assertIn(u"{course_name} has been removed because the enrollment period has closed.".format( - course_name=self.testing_course.display_name), resp.content.decode(resp.charset) - ) - - # now the redemption entry should be deleted from the table. - coupon_redemption = CouponRedemption.objects.filter(coupon__course_id=expired_course_item.course_id, - order=expired_course_item.order_id) - self.assertEqual(coupon_redemption.count(), 0) - ((template, context), _tmp) = render_mock.call_args - self.assertEqual(template, 'shoppingcart/shopping_cart.html') - self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item1, context['shoppingcart_items'][0]) - self.assertEqual(1, len(context['shoppingcart_items'])) - self.assertEqual(True, context['is_course_enrollment_closed']) - self.assertIn(self.testing_course.display_name, context['expired_course_names']) - - def test_to_check_that_cart_item_enrollment_is_closed_when_clicking_the_payment_button(self): - self.login_user() - PaidCourseRegistration.add_to_order( - self.cart, - self.course_key, - mode_slug=self.course_mode.mode_slug - ) - PaidCourseRegistration.add_to_order( - self.cart, - self.testing_course.id, - mode_slug=self.testing_course_mode.mode_slug - ) - - # update the testing_course enrollment dates - self.testing_course.enrollment_start = self.tomorrow - self.testing_course.enrollment_end = self.nextday - self.testing_course = self.update_course(self.testing_course, self.user.id) - - # testing_course enrollment is closed but the course is in the cart - # so we delete that item from the cart and display the message in the cart - resp = self.client.get(reverse('shoppingcart.views.verify_cart')) - self.assertEqual(resp.status_code, 200) - self.assertTrue(json.loads(resp.content.decode('utf-8'))['is_course_enrollment_closed']) - - resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - self.assertContains( - resp, - u"{course_name} has been removed because the enrollment period has closed.".format( - course_name=self.testing_course.display_name - ), - ) - self.assertContains(resp, '40.00') - - def test_is_enrollment_closed_when_order_type_is_business(self): - self.login_user() - self.cart.order_type = 'business' - self.cart.save() - PaidCourseRegistration.add_to_order(self.cart, self.course_key, mode_slug=self.course_mode.mode_slug) - CourseRegCodeItem.add_to_order(self.cart, self.testing_course.id, 2, mode_slug=self.course_mode.mode_slug) - - # update the testing_course enrollment dates - self.testing_course.enrollment_start = self.tomorrow - self.testing_course.enrollment_end = self.nextday - self.testing_course = self.update_course(self.testing_course, self.user.id) - - resp = self.client.post(reverse('billing_details')) - self.assertEqual(resp.status_code, 200) - self.assertTrue(json.loads(resp.content.decode('utf-8'))['is_course_enrollment_closed']) - - # testing_course enrollment is closed but the course is in the cart - # so we delete that item from the cart and display the message in the cart - resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - self.assertContains( - resp, - u"{course_name} has been removed because the enrollment period has closed.".format( - course_name=self.testing_course.display_name - ), - ) - self.assertContains(resp, '40.00') - - -@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) -class RegistrationCodeRedemptionCourseEnrollment(SharedModuleStoreTestCase): - """ - Test suite for RegistrationCodeRedemption Course Enrollments - """ - - ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache'] - - @classmethod - def setUpClass(cls): - super(RegistrationCodeRedemptionCourseEnrollment, cls).setUpClass() - cls.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') - cls.course_key = cls.course.id - - def setUp(self): - super(RegistrationCodeRedemptionCourseEnrollment, self).setUp() - - self.user = UserFactory.create() - self.user.set_password('password') - self.user.save() - self.cost = 40 - self.course_mode = CourseMode(course_id=self.course_key, - mode_slug="honor", - mode_display_name="honor cert", - min_price=self.cost) - self.course_mode.save() - - def login_user(self): - """ - Helper fn to login self.user - """ - self.client.login(username=self.user.username, password="password") - - def test_registration_redemption_post_request_ratelimited(self): - """ - Try (and fail) registration code redemption 30 times - in a row on an non-existing registration code post request - """ - cache.clear() - url = reverse('register_code_redemption', args=['asdasd']) - self.login_user() - for i in range(30): # pylint: disable=unused-variable - response = self.client.post(url) - self.assertEqual(response.status_code, 404) - - # then the rate limiter should kick in and give a HttpForbidden response - response = self.client.post(url) - self.assertEqual(response.status_code, 403) - - # now reset the time to 6 mins from now in future in order to unblock - reset_time = datetime.now(UTC) + timedelta(seconds=361) - with freeze_time(reset_time): - response = self.client.post(url) - self.assertEqual(response.status_code, 404) - - cache.clear() - - def test_registration_redemption_get_request_ratelimited(self): - """ - Try (and fail) registration code redemption 30 times - in a row on an non-existing registration code get request - """ - cache.clear() - url = reverse('register_code_redemption', args=['asdasd']) - self.login_user() - for i in range(30): # pylint: disable=unused-variable - response = self.client.get(url) - self.assertEqual(response.status_code, 404) - - # then the rate limiter should kick in and give a HttpForbidden response - response = self.client.get(url) - self.assertEqual(response.status_code, 403) - - # now reset the time to 6 mins from now in future in order to unblock - reset_time = datetime.now(UTC) + timedelta(seconds=361) - with freeze_time(reset_time): - response = self.client.get(url) - self.assertEqual(response.status_code, 404) - - cache.clear() - - -@ddt.ddt -class DonationViewTest(SharedModuleStoreTestCase): - """Tests for making a donation. - - These tests cover both the single-item purchase flow, - as well as the receipt page for donation items. - """ - - DONATION_AMOUNT = "23.45" - PASSWORD = "password" - - @classmethod - def setUpClass(cls): - super(DonationViewTest, cls).setUpClass() - cls.course = CourseFactory.create(display_name="Test Course") - - def setUp(self): - """Create a test user and order. """ - super(DonationViewTest, self).setUp() - - # Create and login a user - self.user = UserFactory.create() - self.user.set_password(self.PASSWORD) - self.user.save() - result = self.client.login(username=self.user.username, password=self.PASSWORD) - self.assertTrue(result) - - # Enable donations - config = DonationConfiguration.current() - config.enabled = True - config.save() - - def test_donation_for_org(self): - self._donate(self.DONATION_AMOUNT) - self._assert_receipt_contains("tax purposes") - - def test_donation_for_course_receipt(self): - # Donate to our course - self._donate(self.DONATION_AMOUNT, course_id=self.course.id) - - # Verify the receipt page - self._assert_receipt_contains("tax purposes") - self._assert_receipt_contains(self.course.display_name) - - def test_smallest_possible_donation(self): - self._donate("0.01") - self._assert_receipt_contains("0.01") - - @ddt.data( - {}, - {"amount": "abcd"}, - {"amount": "-1.00"}, - {"amount": "0.00"}, - {"amount": "0.001"}, - {"amount": "0"}, - {"amount": "23.45", "course_id": "invalid"} - ) - def test_donation_bad_request(self, bad_params): - response = self.client.post(reverse('donation'), bad_params) - self.assertEqual(response.status_code, 400) - - def test_donation_requires_login(self): - self.client.logout() - response = self.client.post(reverse('donation'), {'amount': self.DONATION_AMOUNT}) - self.assertEqual(response.status_code, 302) - - def test_no_such_course(self): - response = self.client.post( - reverse("donation"), - {"amount": self.DONATION_AMOUNT, "course_id": "edx/DemoX/Demo"} - ) - self.assertEqual(response.status_code, 400) - - @ddt.data("get", "put", "head", "options", "delete") - def test_donation_requires_post(self, invalid_method): - response = getattr(self.client, invalid_method)( - reverse("donation"), {"amount": self.DONATION_AMOUNT} - ) - self.assertEqual(response.status_code, 405) - - def test_donations_disabled(self): - config = DonationConfiguration.current() - config.enabled = False - config.save() - - # Logged in -- should be a 404 - response = self.client.post(reverse('donation')) - self.assertEqual(response.status_code, 404) - - # Logged out -- should still be a 404 - self.client.logout() - response = self.client.post(reverse('donation')) - self.assertEqual(response.status_code, 404) - - def _donate(self, donation_amount, course_id=None): - """Simulate a donation to a course. - - This covers the entire payment flow, except for the external - payment processor, which is simulated. - - Arguments: - donation_amount (unicode): The amount the user is donating. - - Keyword Arguments: - course_id (CourseKey): If provided, make a donation to the specific course. - - Raises: - AssertionError - - """ - # Purchase a single donation item - # Optionally specify a particular course for the donation - params = {'amount': donation_amount} - if course_id is not None: - params['course_id'] = course_id - - url = reverse('donation') - response = self.client.post(url, params) - self.assertEqual(response.status_code, 200) - - # Use the fake payment implementation to simulate the parameters - # we would receive from the payment processor. - payment_info = json.loads(response.content.decode('utf-8')) - self.assertEqual(payment_info["payment_url"], "/shoppingcart/payment_fake") - - # If this is a per-course donation, verify that we're sending - # the course ID to the payment processor. - if course_id is not None: - self.assertEqual( - payment_info["payment_params"]["merchant_defined_data1"], - six.text_type(course_id) - ) - self.assertEqual( - payment_info["payment_params"]["merchant_defined_data2"], - "donation_course" - ) - else: - self.assertEqual(payment_info["payment_params"]["merchant_defined_data1"], "") - self.assertEqual( - payment_info["payment_params"]["merchant_defined_data2"], - "donation_general" - ) - - processor_response_params = PaymentFakeView.response_post_params(payment_info["payment_params"]) - - # Use the response parameters to simulate a successful payment - url = reverse('shoppingcart.views.postpay_callback') - response = self.client.post(url, processor_response_params) - self.assertRedirects(response, self._receipt_url) - - def _assert_receipt_contains(self, expected_text): - """Load the receipt page and verify that it contains the expected text.""" - resp = self.client.get(self._receipt_url) - self.assertContains(resp, expected_text) - - @property - def _receipt_url(self): - order_id = Order.objects.get(user=self.user, status="purchased").id - return reverse("shoppingcart.views.show_receipt", kwargs={"ordernum": order_id}) - - -class CSVReportViewsTest(SharedModuleStoreTestCase): - """ - Test suite for CSV Purchase Reporting - """ - @classmethod - def setUpClass(cls): - super(CSVReportViewsTest, cls).setUpClass() - cls.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') - cls.course_key = cls.course.id - verified_course = CourseFactory.create(org='org', number='test', display_name='Test Course') - cls.verified_course_key = verified_course.id - - def setUp(self): - super(CSVReportViewsTest, self).setUp() - - self.user = UserFactory.create() - self.user.set_password('password') - self.user.save() - self.cost = 40 - self.course_mode = CourseMode(course_id=self.course_key, - mode_slug="honor", - mode_display_name="honor cert", - min_price=self.cost) - self.course_mode.save() - self.course_mode2 = CourseMode(course_id=self.course_key, - mode_slug="verified", - mode_display_name="verified cert", - min_price=self.cost) - self.course_mode2.save() - - self.cart = Order.get_cart_for_user(self.user) - self.dl_grp = Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) - self.dl_grp.save() - - def login_user(self): - """ - Helper fn to login self.user - """ - self.client.login(username=self.user.username, password="password") - - def add_to_download_group(self, user): - """ - Helper fn to add self.user to group that's allowed to download report CSV - """ - user.groups.add(self.dl_grp) - - def test_report_csv_no_access(self): - self.login_user() - response = self.client.get(reverse('payment_csv_report')) - self.assertEqual(response.status_code, 403) - - def test_report_csv_bad_method(self): - self.login_user() - self.add_to_download_group(self.user) - response = self.client.put(reverse('payment_csv_report')) - self.assertEqual(response.status_code, 400) - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_report_csv_get(self): - self.login_user() - self.add_to_download_group(self.user) - response = self.client.get(reverse('payment_csv_report')) - - ((template, context), unused_kwargs) = render_mock.call_args - self.assertEqual(template, 'shoppingcart/download_report.html') - self.assertFalse(context['total_count_error']) - self.assertFalse(context['date_fmt_error']) - self.assertContains(response, "Download CSV Reports") - - @patch('lms.djangoapps.shoppingcart.views.render_to_response', render_mock) - def test_report_csv_bad_date(self): - self.login_user() - self.add_to_download_group(self.user) - response = self.client.post(reverse('payment_csv_report'), - {'start_date': 'BAD', 'end_date': 'BAD', - 'requested_report': 'itemized_purchase_report'}) - - ((template, context), unused_kwargs) = render_mock.call_args - self.assertEqual(template, 'shoppingcart/download_report.html') - self.assertFalse(context['total_count_error']) - self.assertTrue(context['date_fmt_error']) - self.assertContains(response, "There was an error in your date input. It should be formatted as YYYY-MM-DD") - - def test_report_csv_itemized(self): - report_type = 'itemized_purchase_report' - start_date = '1970-01-01' - end_date = '2100-01-01' - PaidCourseRegistration.add_to_order(self.cart, self.course_key, mode_slug=self.course_mode.mode_slug) - self.cart.purchase() - self.login_user() - self.add_to_download_group(self.user) - response = self.client.post(reverse('payment_csv_report'), {'start_date': start_date, - 'end_date': end_date, - 'requested_report': report_type}) - self.assertEqual(response['Content-Type'], 'text/csv') - report = initialize_report(report_type, start_date, end_date) - self.assertContains(response, ",".join(report.header())) - self.assertContains( - response, - ",1,purchased,1,40.00,40.00,usd,Registration for Course: Robot Super Course,", - ) - - def test_report_csv_university_revenue_share(self): - report_type = 'university_revenue_share' - start_date = '1970-01-01' - end_date = '2100-01-01' - start_letter = 'A' - end_letter = 'Z' - self.login_user() - self.add_to_download_group(self.user) - response = self.client.post(reverse('payment_csv_report'), {'start_date': start_date, - 'end_date': end_date, - 'start_letter': start_letter, - 'end_letter': end_letter, - 'requested_report': report_type}) - self.assertEqual(response['Content-Type'], 'text/csv') - report = initialize_report(report_type, start_date, end_date, start_letter, end_letter) - self.assertContains(response, ",".join(report.header())) - - -class UtilFnsTest(TestCase): - """ - Tests for utility functions in views.py - """ - def setUp(self): - super(UtilFnsTest, self).setUp() - - self.user = UserFactory.create() - - def test_can_download_report_no_group(self): - """ - Group controlling perms is not present - """ - self.assertFalse(_can_download_report(self.user)) - - def test_can_download_report_not_member(self): - """ - User is not part of group controlling perms - """ - Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP).save() - self.assertFalse(_can_download_report(self.user)) - - def test_can_download_report(self): - """ - User is part of group controlling perms - """ - grp = Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) - grp.save() - self.user.groups.add(grp) - self.assertTrue(_can_download_report(self.user)) - - def test_get_date_from_str(self): - test_str = "2013-10-01" - date = _get_date_from_str(test_str) - self.assertEqual(2013, date.year) - self.assertEqual(10, date.month) - self.assertEqual(1, date.day) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py deleted file mode 100644 index b165a74df6..0000000000 --- a/lms/djangoapps/shoppingcart/urls.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Defines the shoppingcart URLs -""" - - -from django.conf import settings -from django.conf.urls import url - -from . import views - -urlpatterns = [ - # Both the ~accept and ~reject callback pages are handled here - url(r'^postpay_callback/$', views.postpay_callback, name='shoppingcart.views.postpay_callback'), - - url(r'^receipt/(?P[0-9]*)/$', views.show_receipt, name='shoppingcart.views.show_receipt'), - url(r'^donation/$', views.donate, name='donation'), - url(r'^csv_report/$', views.csv_report, name='payment_csv_report'), - - # These following URLs are only valid if the ENABLE_SHOPPING_CART feature flag is set - url(r'^$', views.show_cart, name='shoppingcart.views.show_cart'), - url(r'^clear/$', views.clear_cart, name='shoppingcart.views.clear_cart'), - url(r'^remove_item/$', views.remove_item, name='shoppingcart.views.remove_item'), - url(r'^add/course/{}/$'.format(settings.COURSE_ID_PATTERN), views.add_course_to_cart, name='add_course_to_cart'), - url(r'^register/redeem/(?P[0-9A-Za-z]+)/$', - views.register_code_redemption, name='register_code_redemption'), - url(r'^use_code/$', views.use_code, name='shoppingcart.views.use_code'), - url(r'^update_user_cart/$', views.update_user_cart, name='shoppingcart.views.update_user_cart'), - url(r'^reset_code_redemption/$', views.reset_code_redemption, name='shoppingcart.views.reset_code_redemption'), - url(r'^billing_details/$', views.billing_details, name='billing_details'), - url(r'^verify_cart/$', views.verify_cart, name='shoppingcart.views.verify_cart'), -] - -if settings.FEATURES.get('ENABLE_PAYMENT_FAKE'): - from shoppingcart.tests.payment_fake import PaymentFakeView - urlpatterns += [ - url(r'^payment_fake', PaymentFakeView.as_view(), name='shoppingcart.views.payment_fake'), - ] diff --git a/lms/djangoapps/shoppingcart/utils.py b/lms/djangoapps/shoppingcart/utils.py deleted file mode 100644 index f6b7ea9477..0000000000 --- a/lms/djangoapps/shoppingcart/utils.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Utility methods for the Shopping Cart app -""" - - -from django.conf import settings -from pdfminer.converter import PDFPageAggregator -from pdfminer.layout import LAParams, LTFigure, LTTextBox, LTTextLine -from pdfminer.pdfdocument import PDFDocument -from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager -from pdfminer.pdfpage import PDFPage -from pdfminer.pdfparser import PDFParser - -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers - - -def is_shopping_cart_enabled(): - """ - Utility method to check the various configuration to verify that - all of the settings have been enabled - """ - enable_paid_course_registration = configuration_helpers.get_value( - 'ENABLE_PAID_COURSE_REGISTRATION', - settings.FEATURES.get('ENABLE_PAID_COURSE_REGISTRATION') - ) - - enable_shopping_cart = configuration_helpers.get_value( - 'ENABLE_SHOPPING_CART', - settings.FEATURES.get('ENABLE_SHOPPING_CART') - ) - - return enable_paid_course_registration and enable_shopping_cart - - -def parse_pages(pdf_buffer, password): - """ - With an PDF buffer object, get the pages, parse each one, and return the entire pdf text - """ - # Create a PDF parser object associated with the file object. - parser = PDFParser(pdf_buffer) - # Create a PDF document object that stores the document structure. - # Supply the password for initialization. - document = PDFDocument(parser, password) - - resource_manager = PDFResourceManager() - la_params = LAParams() - device = PDFPageAggregator(resource_manager, laparams=la_params) - interpreter = PDFPageInterpreter(resource_manager, device) - - text_content = [] # a list of strings, each representing text collected from each page of the doc - for page in PDFPage.create_pages(document): - interpreter.process_page(page) - # receive the LTPage object for this page - layout = device.get_result() - # layout is an LTPage object which may contain - # child objects like LTTextBox, LTFigure, LTImage, etc. - text_content.append(parse_lt_objects(layout._objs)) # pylint: disable=protected-access - - return text_content - - -def parse_lt_objects(lt_objects): - """ - Iterate through the list of LT* objects and capture the text data contained in each object - """ - text_content = [] - - for lt_object in lt_objects: - if isinstance(lt_object, LTTextBox) or isinstance(lt_object, LTTextLine): - # text - text_content.append(lt_object.get_text()) - elif isinstance(lt_object, LTFigure): - # LTFigure objects are containers for other LT* objects, so recurse through the children - text_content.append(parse_lt_objects(lt_object._objs)) # pylint: disable=protected-access - - return '\n'.join(text_content) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py deleted file mode 100644 index 3a4a785e29..0000000000 --- a/lms/djangoapps/shoppingcart/views.py +++ /dev/null @@ -1,1040 +0,0 @@ -"""This module contains views related to shopping cart""" - - -import datetime -import decimal -import json -import logging - -import pytz -import six -from config_models.decorators import require_config -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import Group -from django.db.models import Q -from django.http import ( - Http404, - HttpResponse, - HttpResponseBadRequest, - HttpResponseForbidden, - HttpResponseNotFound, - HttpResponseRedirect -) -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.translation import ugettext as _ -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods, require_POST -from ipware.ip import get_ip -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import CourseLocator - -from course_modes.models import CourseMode -from lms.djangoapps.courseware.courses import get_course_by_id -from edxmako.shortcuts import render_to_response -from openedx.core.djangoapps.embargo import api as embargo_api -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from shoppingcart.reports import ( - CertificateStatusReport, - ItemizedPurchaseReport, - RefundReport, - UniversityRevenueShareReport -) -from student.models import AlreadyEnrolledError, CourseEnrollment, CourseFullError, EnrollmentClosedError -from util.date_utils import get_default_time_display -from util.json_request import JsonResponse -from util.request_rate_limiter import BadRequestRateLimiter - -from .decorators import enforce_shopping_cart_enabled -from .exceptions import ( - AlreadyEnrolledInCourseException, - CourseDoesNotExistException, - InvalidCartItem, - ItemAlreadyInCartException, - ItemNotFoundInCartException, - MultipleCouponsNotAllowedException, - RedemptionCodeError, - ReportTypeDoesNotExistException -) -from .models import ( - CertificateItem, - Coupon, - CouponRedemption, - CourseRegCodeItem, - CourseRegistrationCode, - Donation, - DonationConfiguration, - Order, - OrderItem, - OrderTypes, - PaidCourseRegistration, - RegistrationCodeRedemption -) -from .processors import ( - get_purchase_endpoint, - get_signed_purchase_params, - process_postpay_callback, - render_purchase_form_html -) - -log = logging.getLogger("shoppingcart") -AUDIT_LOG = logging.getLogger("audit") - -EVENT_NAME_USER_UPGRADED = 'edx.course.enrollment.upgrade.succeeded' - -REPORT_TYPES = [ - ("refund_report", RefundReport), - ("itemized_purchase_report", ItemizedPurchaseReport), - ("university_revenue_share", UniversityRevenueShareReport), - ("certificate_status", CertificateStatusReport), -] - - -def initialize_report(report_type, start_date, end_date, start_letter=None, end_letter=None): - """ - Creates the appropriate type of Report object based on the string report_type. - """ - for item in REPORT_TYPES: - if report_type in item: - return item[1](start_date, end_date, start_letter, end_letter) - raise ReportTypeDoesNotExistException - - -@require_POST -def add_course_to_cart(request, course_id): - """ - Adds course specified by course_id to the cart. The model function add_to_order does all the - heavy lifting (logging, error checking, etc) - """ - - assert isinstance(course_id, six.string_types) - if not request.user.is_authenticated: - log.info(u"Anon user trying to add course %s to cart", course_id) - return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart')) - cart = Order.get_cart_for_user(request.user) - course_key = CourseKey.from_string(course_id) - # All logging from here handled by the model - try: - paid_course_item = PaidCourseRegistration.add_to_order(cart, course_key) - except CourseDoesNotExistException: - return HttpResponseNotFound(_('The course you requested does not exist.')) - except ItemAlreadyInCartException: - return HttpResponseBadRequest(_(u'The course {course_id} is already in your cart.').format(course_id=course_id)) - except AlreadyEnrolledInCourseException: - return HttpResponseBadRequest( - _(u'You are already registered in course {course_id}.').format(course_id=course_id)) - else: - # in case a coupon redemption code has been applied, new items should also get a discount if applicable. - order = paid_course_item.order - order_items = order.orderitem_set.all().select_subclasses() - redeemed_coupons = CouponRedemption.objects.filter(order=order) - for redeemed_coupon in redeemed_coupons: - if Coupon.objects.filter(code=redeemed_coupon.coupon.code, course_id=course_key, is_active=True).exists(): - coupon = Coupon.objects.get(code=redeemed_coupon.coupon.code, course_id=course_key, is_active=True) - CouponRedemption.add_coupon_redemption(coupon, order, order_items) - break # Since only one code can be applied to the cart, we'll just take the first one and then break. - - return HttpResponse(_("Course added to cart.")) - - -@login_required -@enforce_shopping_cart_enabled -def update_user_cart(request): - """ - when user change the number-of-students from the UI then - this method Update the corresponding qty field in OrderItem model and update the order_type in order model. - """ - try: - qty = int(request.POST.get('qty', -1)) - except ValueError: - log.exception('Quantity must be an integer.') - return HttpResponseBadRequest('Quantity must be an integer.') - - if not 1 <= qty <= 1000: - log.warning('Quantity must be between 1 and 1000.') - return HttpResponseBadRequest('Quantity must be between 1 and 1000.') - - item_id = request.POST.get('ItemId', None) - if item_id: - try: - item = OrderItem.objects.get(id=item_id, status='cart') - except OrderItem.DoesNotExist: - log.exception(u'Cart OrderItem id=%s DoesNotExist', item_id) - return HttpResponseNotFound('Order item does not exist.') - - item.qty = qty - item.save() - old_to_new_id_map = item.order.update_order_type() - total_cost = item.order.total_cost - - callback_url = request.build_absolute_uri( - reverse("shoppingcart.views.postpay_callback") - ) - cart = Order.get_cart_for_user(request.user) - form_html = render_purchase_form_html(cart, callback_url=callback_url) - - return JsonResponse( - { - "total_cost": total_cost, - "oldToNewIdMap": old_to_new_id_map, - "form_html": form_html, - } - ) - - return HttpResponseBadRequest('Order item not found in request.') - - -@login_required -@enforce_shopping_cart_enabled -def show_cart(request): - """ - This view shows cart items. - """ - cart = Order.get_cart_for_user(request.user) - is_any_course_expired, expired_cart_items, expired_cart_item_names, valid_cart_item_tuples = \ - verify_for_closed_enrollment(request.user, cart) - site_name = configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME) - - if is_any_course_expired: - for expired_item in expired_cart_items: - Order.remove_cart_item_from_order(expired_item, request.user) - cart.update_order_type() - - callback_url = request.build_absolute_uri( - reverse("shoppingcart.views.postpay_callback") - ) - form_html = render_purchase_form_html(cart, callback_url=callback_url) - context = { - 'order': cart, - 'shoppingcart_items': valid_cart_item_tuples, - 'amount': cart.total_cost, - 'is_course_enrollment_closed': is_any_course_expired, - 'expired_course_names': expired_cart_item_names, - 'site_name': site_name, - 'form_html': form_html, - 'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1], - 'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0], - 'enable_bulk_purchase': configuration_helpers.get_value('ENABLE_SHOPPING_CART_BULK_PURCHASE', True) - } - return render_to_response("shoppingcart/shopping_cart.html", context) - - -@login_required -@enforce_shopping_cart_enabled -def clear_cart(request): - cart = Order.get_cart_for_user(request.user) - cart.clear() - coupon_redemption = CouponRedemption.objects.filter(user=request.user, order=cart.id) - if coupon_redemption: - coupon_redemption.delete() - log.info( - u'Coupon redemption entry removed for user %s for order %s', - request.user, - cart.id, - ) - - return HttpResponse('Cleared') - - -@login_required -@enforce_shopping_cart_enabled -def remove_item(request): - """ - This will remove an item from the user cart and also delete the corresponding coupon codes redemption. - """ - item_id = request.GET.get('id') or request.POST.get('id') or '-1' - - items = OrderItem.objects.filter(id=item_id, status='cart').select_subclasses() - - if not len(items): - log.exception( - u'Cannot remove cart OrderItem id=%s. DoesNotExist or item is already purchased', - item_id - ) - else: - # Reload the item directly to prevent select_subclasses() hackery from interfering with - # deletion of all objects in the model inheritance hierarchy - item = items[0].__class__.objects.get(id=item_id) - if item.user == request.user: - Order.remove_cart_item_from_order(item, request.user) - item.order.update_order_type() - - return HttpResponse('OK') - - -@login_required -@enforce_shopping_cart_enabled -def reset_code_redemption(request): - """ - This method reset the code redemption from user cart items. - """ - cart = Order.get_cart_for_user(request.user) - cart.reset_cart_items_prices() - CouponRedemption.remove_coupon_redemption_from_cart(request.user, cart) - return HttpResponse('reset') - - -@login_required -@enforce_shopping_cart_enabled -def use_code(request): - """ - Valid Code can be either Coupon or Registration code. - For a valid Coupon Code, this applies the coupon code and generates a discount against all applicable items. - For a valid Registration code, it deletes the item from the shopping cart and redirects to the - Registration Code Redemption page. - """ - code = request.POST["code"] - coupons = Coupon.objects.filter( - Q(code=code), - Q(is_active=True), - Q(expiration_date__gt=datetime.datetime.now(pytz.UTC)) | - Q(expiration_date__isnull=True) - ) - if not coupons: - # If no coupons then we check that code against course registration code - try: - course_reg = CourseRegistrationCode.objects.get(code=code) - except CourseRegistrationCode.DoesNotExist: - return HttpResponseNotFound(_(u"Discount does not exist against code '{code}'.").format(code=code)) - - return use_registration_code(course_reg, request.user) - - return use_coupon_code(coupons, request.user) - - -def get_reg_code_validity(registration_code, request, limiter): - """ - This function checks if the registration code is valid, and then checks if it was already redeemed. - """ - reg_code_already_redeemed = False - course_registration = None - try: - course_registration = CourseRegistrationCode.objects.get(code=registration_code) - except CourseRegistrationCode.DoesNotExist: - reg_code_is_valid = False - else: - if course_registration.is_valid: - reg_code_is_valid = True - else: - reg_code_is_valid = False - reg_code_already_redeemed = RegistrationCodeRedemption.is_registration_code_redeemed(registration_code) - if not reg_code_is_valid: - # tick the rate limiter counter - AUDIT_LOG.info(u"Redemption of a invalid RegistrationCode %s", registration_code) - limiter.tick_request_counter(request) - raise Http404() - - return reg_code_is_valid, reg_code_already_redeemed, course_registration - - -@require_http_methods(["GET", "POST"]) -@login_required -def register_code_redemption(request, registration_code): - """ - This view allows the student to redeem the registration code - and enroll in the course. - """ - - # Add some rate limiting here by re-using the RateLimitMixin as a helper class - site_name = configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME) - limiter = BadRequestRateLimiter() - if limiter.is_rate_limit_exceeded(request): - AUDIT_LOG.warning("Rate limit exceeded in registration code redemption.") - return HttpResponseForbidden() - - template_to_render = 'shoppingcart/registration_code_redemption.html' - if request.method == "GET": - reg_code_is_valid, reg_code_already_redeemed, course_registration = get_reg_code_validity(registration_code, - request, limiter) - course = get_course_by_id(course_registration.course_id, depth=0) - - # Restrict the user from enrolling based on country access rules - embargo_redirect = embargo_api.redirect_if_blocked( - course.id, user=request.user, ip_address=get_ip(request), - url=request.path - ) - if embargo_redirect is not None: - return redirect(embargo_redirect) - - context = { - 'reg_code_already_redeemed': reg_code_already_redeemed, - 'reg_code_is_valid': reg_code_is_valid, - 'reg_code': registration_code, - 'site_name': site_name, - 'course': course, - 'registered_for_course': not _is_enrollment_code_an_update(course, request.user, course_registration) - } - return render_to_response(template_to_render, context) - elif request.method == "POST": - reg_code_is_valid, reg_code_already_redeemed, course_registration = get_reg_code_validity( - registration_code, - request, - limiter - ) - course = get_course_by_id(course_registration.course_id, depth=0) - - # Restrict the user from enrolling based on country access rules - embargo_redirect = embargo_api.redirect_if_blocked( - course.id, user=request.user, ip_address=get_ip(request), - url=request.path - ) - if embargo_redirect is not None: - return redirect(embargo_redirect) - - context = { - 'reg_code': registration_code, - 'site_name': site_name, - 'course': course, - 'reg_code_is_valid': reg_code_is_valid, - 'reg_code_already_redeemed': reg_code_already_redeemed, - } - if reg_code_is_valid and not reg_code_already_redeemed: - # remove the course from the cart if it was added there. - cart = Order.get_cart_for_user(request.user) - try: - cart_items = cart.find_item_by_course_id(course_registration.course_id) - - except ItemNotFoundInCartException: - pass - else: - for cart_item in cart_items: - if isinstance(cart_item, PaidCourseRegistration) or isinstance(cart_item, CourseRegCodeItem): - # Reload the item directly to prevent select_subclasses() hackery from interfering with - # deletion of all objects in the model inheritance hierarchy - cart_item = cart_item.__class__.objects.get(id=cart_item.id) - cart_item.delete() - - #now redeem the reg code. - redemption = RegistrationCodeRedemption.create_invoice_generated_registration_redemption(course_registration, request.user) - try: - kwargs = {} - if course_registration.mode_slug is not None: - if CourseMode.mode_for_course(course.id, course_registration.mode_slug): - kwargs['mode'] = course_registration.mode_slug - else: - raise RedemptionCodeError() - redemption.course_enrollment = CourseEnrollment.enroll(request.user, course.id, **kwargs) - redemption.save() - context['redemption_success'] = True - except RedemptionCodeError: - context['redeem_code_error'] = True - context['redemption_success'] = False - except EnrollmentClosedError: - context['enrollment_closed'] = True - context['redemption_success'] = False - except CourseFullError: - context['course_full'] = True - context['redemption_success'] = False - except AlreadyEnrolledError: - context['registered_for_course'] = True - context['redemption_success'] = False - else: - context['redemption_success'] = False - return render_to_response(template_to_render, context) - - -def _is_enrollment_code_an_update(course, user, redemption_code): - """Checks to see if the user's enrollment can be updated by the code. - - Check to see if the enrollment code and the user's enrollment match. If they are different, the code - may be used to alter the enrollment of the user. If the enrollment is inactive, will return True, since - the user may use the code to re-activate an enrollment as well. - - Enrollment redemption codes must be associated with a paid course mode. If the current enrollment is a - different mode then the mode associated with the code, use of the code can be considered an upgrade. - - Args: - course (CourseDescriptor): The course to check for enrollment. - user (User): The user that will be using the redemption code. - redemption_code (CourseRegistrationCode): The redemption code that will be used to update the user's enrollment. - - Returns: - True if the redemption code can be used to upgrade the enrollment, or re-activate it. - - """ - enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course.id) - return not is_active or enrollment_mode != redemption_code.mode_slug - - -def use_registration_code(course_reg, user): - """ - This method utilize course registration code. - If the registration code is invalid, it returns an error. - If the registration code is already redeemed, it returns an error. - Else, it identifies and removes the applicable OrderItem from the Order - and redirects the user to the Registration code redemption page. - """ - if not course_reg.is_valid: - log.warning(u"The enrollment code (%s) is no longer valid.", course_reg.code) - return HttpResponseBadRequest( - _(u"This enrollment code ({enrollment_code}) is no longer valid.").format( - enrollment_code=course_reg.code - ) - ) - - if RegistrationCodeRedemption.is_registration_code_redeemed(course_reg.code): - log.warning(u"This enrollment code ({%s}) has already been used.", course_reg.code) - return HttpResponseBadRequest( - _(u"This enrollment code ({enrollment_code}) is not valid.").format( - enrollment_code=course_reg.code - ) - ) - try: - cart = Order.get_cart_for_user(user) - cart_items = cart.find_item_by_course_id(course_reg.course_id) - except ItemNotFoundInCartException: - log.warning(u"Course item does not exist against registration code '%s'", course_reg.code) - return HttpResponseNotFound( - _(u"Code '{registration_code}' is not valid for any course in the shopping cart.").format( - registration_code=course_reg.code - ) - ) - else: - applicable_cart_items = [ - cart_item for cart_item in cart_items - if isinstance(cart_item, (CourseRegCodeItem, PaidCourseRegistration)) and cart_item.qty == 1 - ] - if not applicable_cart_items: - return HttpResponseNotFound( - _("Cart item quantity should not be greater than 1 when applying activation code")) - - redemption_url = reverse('register_code_redemption', kwargs={'registration_code': course_reg.code}) - return HttpResponse( - json.dumps({'response': 'success', 'coupon_code_applied': False, 'redemption_url': redemption_url}), - content_type="application/json" - ) - - -def use_coupon_code(coupons, user): - """ - This method utilize course coupon code - """ - cart = Order.get_cart_for_user(user) - cart_items = cart.orderitem_set.all().select_subclasses() - is_redemption_applied = False - for coupon in coupons: - try: - if CouponRedemption.add_coupon_redemption(coupon, cart, cart_items): - is_redemption_applied = True - except MultipleCouponsNotAllowedException: - return HttpResponseBadRequest(_("Only one coupon redemption is allowed against an order")) - - if not is_redemption_applied: - log.warning(u"Discount does not exist against code '%s'.", coupons[0].code) - return HttpResponseNotFound(_(u"Discount does not exist against code '{code}'.").format(code=coupons[0].code)) - - return HttpResponse( - json.dumps({'response': 'success', 'coupon_code_applied': True}), - content_type="application/json" - ) - - -@require_config(DonationConfiguration) -@csrf_exempt -@require_POST -@login_required -def donate(request): - """Add a single donation item to the cart and proceed to payment. - - Warning: this call will clear all the items in the user's cart - before adding the new item! - - Arguments: - request (Request): The Django request object. This should contain - a JSON-serialized dictionary with "amount" (string, required), - and "course_id" (slash-separated course ID string, optional). - - Returns: - HttpResponse: 200 on success with JSON-encoded dictionary that has keys - "payment_url" (string) and "payment_params" (dictionary). The client - should POST the payment params to the payment URL. - HttpResponse: 400 invalid amount or course ID. - HttpResponse: 404 donations are disabled. - HttpResponse: 405 invalid request method. - - Example usage: - - POST /shoppingcart/donation/ - with params {'amount': '12.34', course_id': 'edX/DemoX/Demo_Course'} - will respond with the signed purchase params - that the client can send to the payment processor. - - """ - amount = request.POST.get('amount') - course_id = request.POST.get('course_id') - - # Check that required parameters are present and valid - if amount is None: - msg = u"Request is missing required param 'amount'" - log.error(msg) - return HttpResponseBadRequest(msg) - try: - amount = ( - decimal.Decimal(amount) - ).quantize( - decimal.Decimal('.01'), - rounding=decimal.ROUND_DOWN - ) - except decimal.InvalidOperation: - return HttpResponseBadRequest("Could not parse 'amount' as a decimal") - - # Any amount is okay as long as it's greater than 0 - # Since we've already quantized the amount to 0.01 - # and rounded down, we can check if it's less than 0.01 - if amount < decimal.Decimal('0.01'): - return HttpResponseBadRequest("Amount must be greater than 0") - - if course_id is not None: - try: - course_id = CourseLocator.from_string(course_id) - except InvalidKeyError: - msg = u"Request included an invalid course key: {course_key}".format(course_key=course_id) - log.error(msg) - return HttpResponseBadRequest(msg) - - # Add the donation to the user's cart - cart = Order.get_cart_for_user(request.user) - cart.clear() - - try: - # Course ID may be None if this is a donation to the entire organization - Donation.add_to_order(cart, amount, course_id=course_id) - except InvalidCartItem as ex: - log.exception( - u"Could not create donation item for amount '%s' and course ID '%s'", - amount, - course_id - ) - return HttpResponseBadRequest(six.text_type(ex)) - - # Start the purchase. - # This will "lock" the purchase so the user can't change - # the amount after we send the information to the payment processor. - # If the user tries to make another donation, it will be added - # to a new cart. - cart.start_purchase() - - # Construct the response params (JSON-encoded) - callback_url = request.build_absolute_uri( - reverse("shoppingcart.views.postpay_callback") - ) - - # Add extra to make it easier to track transactions - extra_data = [ - six.text_type(course_id) if course_id else "", - "donation_course" if course_id else "donation_general" - ] - - response_params = json.dumps({ - # The HTTP end-point for the payment processor. - "payment_url": get_purchase_endpoint(), - - # Parameters the client should send to the payment processor - "payment_params": get_signed_purchase_params( - cart, - callback_url=callback_url, - extra_data=extra_data - ), - }) - - return HttpResponse(response_params, content_type="text/json") - - -def _get_verify_flow_redirect(order): - """Check if we're in the verification flow and redirect if necessary. - - Arguments: - order (Order): The order received by the post-pay callback. - - Returns: - HttpResponseRedirect or None - - """ - # See if the order contained any certificate items - # If so, the user is coming from the payment/verification flow. - cert_items = CertificateItem.objects.filter(order=order) - - if cert_items.count() > 0: - # Currently, we allow the purchase of only one verified - # enrollment at a time; if there are more than one, - # this will choose the first. - if cert_items.count() > 1: - log.warning( - u"More than one certificate item in order %s; " - u"continuing with the payment/verification flow for " - u"the first order item (course %s).", - order.id, cert_items[0].course_id - ) - - course_id = cert_items[0].course_id - url = reverse( - 'verify_student_payment_confirmation', - kwargs={'course_id': six.text_type(course_id)} - ) - # Add a query string param for the order ID - # This allows the view to query for the receipt information later. - url += '?payment-order-num={order_num}'.format(order_num=order.id) - return HttpResponseRedirect(url) - - -@csrf_exempt -@require_POST -def postpay_callback(request): - """ - Receives the POST-back from processor. - Mainly this calls the processor-specific code to check if the payment was accepted, and to record the order - if it was, and to generate an error page. - If successful this function should have the side effect of changing the "cart" into a full "order" in the DB. - The cart can then render a success page which links to receipt pages. - If unsuccessful the order will be left untouched and HTML messages giving more detailed error info will be - returned. - """ - params = request.POST.dict() - result = process_postpay_callback(params) - - if result['success']: - # See if this payment occurred as part of the verification flow process - # If so, send the user back into the flow so they have the option - # to continue with verification. - - # Only orders where order_items.count() == 1 might be attempting to upgrade - attempting_upgrade = request.session.get('attempting_upgrade', False) - if attempting_upgrade: - if result['order'].has_items(CertificateItem): - course_id = result['order'].orderitem_set.all().select_subclasses("certificateitem")[0].course_id - if course_id: - course_enrollment = CourseEnrollment.get_enrollment(request.user, course_id) - if course_enrollment: - course_enrollment.emit_event(EVENT_NAME_USER_UPGRADED) - - request.session['attempting_upgrade'] = False - - verify_flow_redirect = _get_verify_flow_redirect(result['order']) - if verify_flow_redirect is not None: - return verify_flow_redirect - - # Otherwise, send the user to the receipt page - return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) - else: - request.session['attempting_upgrade'] = False - return render_to_response('shoppingcart/error.html', {'order': result['order'], - 'error_html': result['error_html']}) - - -@require_http_methods(["GET", "POST"]) -@login_required -@enforce_shopping_cart_enabled -def billing_details(request): - """ - This is the view for capturing additional billing details - in case of the business purchase workflow. - """ - - cart = Order.get_cart_for_user(request.user) - cart_items = cart.orderitem_set.all().select_subclasses() - if cart.order_type != OrderTypes.BUSINESS: - raise Http404('Page not found!') - - if request.method == "GET": - callback_url = request.build_absolute_uri( - reverse("shoppingcart.views.postpay_callback") - ) - form_html = render_purchase_form_html(cart, callback_url=callback_url) - total_cost = cart.total_cost - context = { - 'shoppingcart_items': cart_items, - 'amount': total_cost, - 'form_html': form_html, - 'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1], - 'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0], - 'site_name': configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME), - } - return render_to_response("shoppingcart/billing_details.html", context) - elif request.method == "POST": - company_name = request.POST.get("company_name", "") - company_contact_name = request.POST.get("company_contact_name", "") - company_contact_email = request.POST.get("company_contact_email", "") - recipient_name = request.POST.get("recipient_name", "") - recipient_email = request.POST.get("recipient_email", "") - customer_reference_number = request.POST.get("customer_reference_number", "") - - cart.add_billing_details(company_name, company_contact_name, company_contact_email, recipient_name, - recipient_email, customer_reference_number) - - is_any_course_expired, __, __, __ = verify_for_closed_enrollment(request.user) - - return JsonResponse({ - 'response': _('success'), - 'is_course_enrollment_closed': is_any_course_expired - }) # status code 200: OK by default - - -def verify_for_closed_enrollment(user, cart=None): - """ - A multi-output helper function. - inputs: - user: a user object - cart: If a cart is provided it uses the same object, otherwise fetches the user's cart. - Returns: - is_any_course_expired: True if any of the items in the cart has it's enrollment period closed. False otherwise. - expired_cart_items: List of courses with enrollment period closed. - expired_cart_item_names: List of names of the courses with enrollment period closed. - valid_cart_item_tuples: List of courses which are still open for enrollment. - """ - if cart is None: - cart = Order.get_cart_for_user(user) - expired_cart_items = [] - expired_cart_item_names = [] - valid_cart_item_tuples = [] - cart_items = cart.orderitem_set.all().select_subclasses() - is_any_course_expired = False - for cart_item in cart_items: - course_key = getattr(cart_item, 'course_id', None) - if course_key is not None: - course = get_course_by_id(course_key, depth=0) - if CourseEnrollment.is_enrollment_closed(user, course): - is_any_course_expired = True - expired_cart_items.append(cart_item) - expired_cart_item_names.append(course.display_name) - else: - valid_cart_item_tuples.append((cart_item, course)) - - return is_any_course_expired, expired_cart_items, expired_cart_item_names, valid_cart_item_tuples - - -@require_http_methods(["GET"]) -@login_required -@enforce_shopping_cart_enabled -def verify_cart(request): - """ - Called when the user clicks the button to transfer control to CyberSource. - Returns a JSON response with is_course_enrollment_closed set to True if any of the courses has its - enrollment period closed. If all courses are still valid, is_course_enrollment_closed set to False. - """ - is_any_course_expired, __, __, __ = verify_for_closed_enrollment(request.user) - return JsonResponse( - { - 'is_course_enrollment_closed': is_any_course_expired - } - ) # status code 200: OK by default - - -@login_required -def show_receipt(request, ordernum): - """ - Displays a receipt for a particular order. - 404 if order is not yet purchased or request.user != order.user - """ - try: - order = Order.objects.get(id=ordernum) - except Order.DoesNotExist: - raise Http404('Order not found!') - - if order.user != request.user or order.status not in ['purchased', 'refunded']: - raise Http404('Order not found!') - - if 'application/json' in request.META.get('HTTP_ACCEPT', ""): - return _show_receipt_json(order) - else: - return _show_receipt_html(request, order) - - -def _show_receipt_json(order): - """Render the receipt page as JSON. - - The included information is deliberately minimal: - as much as possible, the included information should - be common to *all* order items, so the client doesn't - need to handle different item types differently. - - Arguments: - request (HttpRequest): The request for the receipt. - order (Order): The order model to display. - - Returns: - HttpResponse - - """ - order_info = { - 'orderNum': order.id, - 'currency': order.currency, - 'status': order.status, - 'purchase_datetime': get_default_time_display(order.purchase_time) if order.purchase_time else None, - 'billed_to': { - 'first_name': order.bill_to_first, - 'last_name': order.bill_to_last, - 'street1': order.bill_to_street1, - 'street2': order.bill_to_street2, - 'city': order.bill_to_city, - 'state': order.bill_to_state, - 'postal_code': order.bill_to_postalcode, - 'country': order.bill_to_country, - }, - 'total_cost': order.total_cost, - 'items': [ - { - 'quantity': item.qty, - 'unit_cost': item.unit_cost, - 'line_cost': item.line_cost, - 'line_desc': item.line_desc, - 'course_key': six.text_type(item.course_id) - } - for item in OrderItem.objects.filter(order=order).select_subclasses() - ] - } - return JsonResponse(order_info) - - -def _show_receipt_html(request, order): - """Render the receipt page as HTML. - - Arguments: - request (HttpRequest): The request for the receipt. - order (Order): The order model to display. - - Returns: - HttpResponse - - """ - order_items = OrderItem.objects.filter(order=order).select_subclasses() - shoppingcart_items = [] - course_names_list = [] - for order_item in order_items: - course_key = order_item.course_id - if course_key: - course = get_course_by_id(course_key, depth=0) - shoppingcart_items.append((order_item, course)) - course_names_list.append(course.display_name) - - appended_course_names = ", ".join(course_names_list) - any_refunds = any(i.status == "refunded" for i in order_items) - receipt_template = 'shoppingcart/receipt.html' - __, instructions = order.generate_receipt_instructions() - order_type = order.order_type - - recipient_list = [] - total_registration_codes = None - reg_code_info_list = [] - recipient_list.append(order.user.email) - if order_type == OrderTypes.BUSINESS: - if order.company_contact_email: - recipient_list.append(order.company_contact_email) - if order.recipient_email: - recipient_list.append(order.recipient_email) - - for __, course in shoppingcart_items: - course_registration_codes = CourseRegistrationCode.objects.filter(order=order, course_id=course.id) - total_registration_codes = course_registration_codes.count() - for course_registration_code in course_registration_codes: - reg_code_info_list.append({ - 'course_name': course.display_name, - 'redemption_url': reverse('register_code_redemption', args=[course_registration_code.code]), - 'code': course_registration_code.code, - 'is_valid': course_registration_code.is_valid, - 'is_redeemed': RegistrationCodeRedemption.objects.filter( - registration_code=course_registration_code).exists(), - }) - - appended_recipient_emails = ", ".join(recipient_list) - - context = { - 'order': order, - 'shoppingcart_items': shoppingcart_items, - 'any_refunds': any_refunds, - 'instructions': instructions, - 'site_name': configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME), - 'order_type': order_type, - 'appended_course_names': appended_course_names, - 'appended_recipient_emails': appended_recipient_emails, - 'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1], - 'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0], - 'total_registration_codes': total_registration_codes, - 'reg_code_info_list': reg_code_info_list, - 'order_purchase_date': order.purchase_time.strftime(u"%B %d, %Y"), - } - - # We want to have the ability to override the default receipt page when - # there is only one item in the order. - if order_items.count() == 1: - receipt_template = order_items[0].single_item_receipt_template - context.update(order_items[0].single_item_receipt_context) - - return render_to_response(receipt_template, context) - - -def _can_download_report(user): - """ - Tests if the user can download the payments report, based on membership in a group whose name is determined - in settings. If the group does not exist, denies all access - """ - try: - access_group = Group.objects.get(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) - except Group.DoesNotExist: - return False - return access_group in user.groups.all() - - -def _get_date_from_str(date_input): - """ - Gets date from the date input string. Lets the ValueError raised by invalid strings be processed by the caller - """ - return datetime.datetime.strptime(date_input.strip(), "%Y-%m-%d").replace(tzinfo=pytz.UTC) - - -def _render_report_form(start_str, end_str, start_letter, end_letter, report_type, total_count_error=False, date_fmt_error=False): - """ - Helper function that renders the purchase form. Reduces repetition - """ - context = { - 'total_count_error': total_count_error, - 'date_fmt_error': date_fmt_error, - 'start_date': start_str, - 'end_date': end_str, - 'start_letter': start_letter, - 'end_letter': end_letter, - 'requested_report': report_type, - } - return render_to_response('shoppingcart/download_report.html', context) - - -@login_required -def csv_report(request): - """ - Downloads csv reporting of orderitems - """ - if not _can_download_report(request.user): - return HttpResponseForbidden(_('You do not have permission to view this page.')) - - if request.method == 'POST': - start_date = request.POST.get('start_date', '') - end_date = request.POST.get('end_date', '') - start_letter = request.POST.get('start_letter', '') - end_letter = request.POST.get('end_letter', '') - report_type = request.POST.get('requested_report', '') - try: - start_date = _get_date_from_str(start_date) + datetime.timedelta(days=0) - end_date = _get_date_from_str(end_date) + datetime.timedelta(days=1) - except ValueError: - # Error case: there was a badly formatted user-input date string - return _render_report_form(start_date, end_date, start_letter, end_letter, report_type, date_fmt_error=True) - - report = initialize_report(report_type, start_date, end_date, start_letter, end_letter) - items = report.rows() - - response = HttpResponse(content_type='text/csv') - filename = u"purchases_report_{}.csv".format(datetime.datetime.now(pytz.UTC).strftime(u"%Y-%m-%d-%H-%M-%S")) - response['Content-Disposition'] = u'attachment; filename="{}"'.format(filename) - report.write_csv(response) - return response - - elif request.method == 'GET': - end_date = datetime.datetime.now(pytz.UTC) - start_date = end_date - datetime.timedelta(days=30) - start_letter = "" - end_letter = "" - return _render_report_form(start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d"), start_letter, end_letter, report_type="") - - else: - return HttpResponseBadRequest("HTTP Method Not Supported") diff --git a/lms/djangoapps/verify_student/tests/test_integration.py b/lms/djangoapps/verify_student/tests/test_integration.py index 4a98001dd6..be12b7fffc 100644 --- a/lms/djangoapps/verify_student/tests/test_integration.py +++ b/lms/djangoapps/verify_student/tests/test_integration.py @@ -2,11 +2,11 @@ Integration tests of the payment flow, including course mode selection. """ - import six from django.urls import reverse from course_modes.tests.factories import CourseModeFactory +from lms.djangoapps.commerce.tests.mocks import mock_payment_processors from student.models import CourseEnrollment from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -51,8 +51,9 @@ class TestProfEdVerification(ModuleStoreTestCase): # Go to the course mode page, expecting a redirect to the intro step of the # payment flow (since this is a professional ed course). Otherwise, the student # would have the option to choose their track. - resp = self.client.get(self.urls['course_modes_choose'], follow=True) - self.assertRedirects(resp, self.urls['verify_student_start_flow']) + with mock_payment_processors(): + resp = self.client.get(self.urls['course_modes_choose'], follow=True) + self.assertRedirects(resp, self.urls['verify_student_start_flow']) # For professional ed courses, expect that the student is NOT enrolled # automatically in the course. diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 30414f6043..c0f05e1e70 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -19,9 +19,7 @@ from django.test.client import Client, RequestFactory from django.test.utils import override_settings from django.urls import reverse from django.utils.timezone import now -from django.utils.translation import ugettext as _ from mock import Mock, patch -from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from six.moves import zip from waffle.testutils import override_switch @@ -31,6 +29,7 @@ from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory from lms.djangoapps.commerce.models import CommerceConfiguration from lms.djangoapps.commerce.tests import TEST_API_URL, TEST_PAYMENT_DATA, TEST_PUBLIC_URL_ROOT +from lms.djangoapps.commerce.tests.mocks import mock_payment_processors from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline from lms.djangoapps.verify_student.views import PayAndVerifyView, checkout_with_ecommerce_service, render_to_response @@ -56,6 +55,20 @@ render_mock = Mock(side_effect=mock_render_to_response) PAYMENT_DATA_KEYS = {'payment_processor_name', 'payment_page_url', 'payment_form_data'} +def _mock_payment_processors(): + """ + Mock out the payment processors endpoint, since we don't run ecommerce for unit tests here. + Used in tests where ``mock_payment_processors`` can't be easily used, for example the whole + test is an httpretty context or the mock may or may not be called depending on ddt parameters. + """ + httpretty.register_uri( + httpretty.GET, + "{}/payment/processors/".format(TEST_API_URL), + body=json.dumps(['foo', 'bar']), + content_type="application/json", + ) + + class StartView(TestCase): """ This view is for the first time student is @@ -135,12 +148,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin, Tes """Verify user gets redirected to ecommerce checkout when ecommerce checkout is enabled.""" sku = 'TESTSKU' # When passing a SKU ecommerce api gets called. - httpretty.register_uri( - httpretty.GET, - "{}/payment/processors/".format(TEST_API_URL), - body=json.dumps(['foo', 'bar']), - content_type="application/json", - ) + _mock_payment_processors() configuration = CommerceConfiguration.objects.create(checkout_on_ecommerce_service=True) checkout_page = configuration.basket_checkout_page checkout_page += "?utm_source=test" @@ -842,15 +850,24 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin, Tes } session.save() - def _get_page(self, url_name, course_key, expected_status_code=200, skip_first_step=False): + @httpretty.activate + @override_settings(ECOMMERCE_API_URL=TEST_API_URL) + def _get_page(self, url_name, course_key, expected_status_code=200, skip_first_step=False, assert_headers=False): """Retrieve one of the verification pages. """ url = reverse(url_name, kwargs={"course_id": six.text_type(course_key)}) if skip_first_step: url += "?skip-first-step=1" + _mock_payment_processors() response = self.client.get(url) self.assertEqual(response.status_code, expected_status_code) + + if assert_headers: + # ensure the mock api call was made. NOTE: the following line + # approximates the check - if the headers were empty it means + # there was no last request. + self.assertNotEqual(httpretty.last_request().headers, {}) return response def _assert_displayed_mode(self, response, expected_mode): @@ -937,17 +954,20 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin, Tes def _assert_redirects_to_start_flow(self, response, course_id): """Check that the page redirects to the start of the payment/verification flow. """ url = reverse('verify_student_start_flow', kwargs={'course_id': six.text_type(course_id)}) - self.assertRedirects(response, url) + with mock_payment_processors(): + self.assertRedirects(response, url) def _assert_redirects_to_verify_start(self, response, course_id, status_code=302): """Check that the page redirects to the "verify later" part of the flow. """ url = reverse('verify_student_verify_now', kwargs={'course_id': six.text_type(course_id)}) - self.assertRedirects(response, url, status_code) + with mock_payment_processors(): + self.assertRedirects(response, url, status_code) def _assert_redirects_to_upgrade(self, response, course_id): """Check that the page redirects to the "upgrade" part of the flow. """ url = reverse('verify_student_upgrade_and_verify', kwargs={'course_id': six.text_type(course_id)}) - self.assertRedirects(response, url) + with mock_payment_processors(): + self.assertRedirects(response, url) @ddt.data("verify_student_start_flow", "verify_student_begin_flow") def test_course_upgrade_page_with_unicode_and_special_values_in_display_name(self, payment_flow): @@ -968,8 +988,6 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin, Tes self.assertEqual(response_dict['course_name'], mode_display_name) - @httpretty.activate - @override_settings(ECOMMERCE_API_URL=TEST_API_URL) @ddt.data("verify_student_start_flow", "verify_student_begin_flow") def test_processors_api(self, payment_flow): """ @@ -982,27 +1000,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin, Tes course = self._create_course("verified", sku='nonempty-sku') self._enroll(course.id) - # mock out the payment processors endpoint - httpretty.register_uri( - httpretty.GET, - "{}/payment/processors/".format(TEST_API_URL), - body=json.dumps(['foo', 'bar']), - content_type="application/json", - ) # make the server request - response = self._get_page(payment_flow, course.id) + response = self._get_page(payment_flow, course.id, assert_headers=True) self.assertEqual(response.status_code, 200) - # ensure the mock api call was made. NOTE: the following line - # approximates the check - if the headers were empty it means - # there was no last request. - self.assertNotEqual(httpretty.last_request().headers, {}) - class CheckoutTestMixin(object): """ Mixin implementing test methods that should behave identically regardless - of which backend is used (shoppingcart or ecommerce service). Subclasses + of which backend is used (currently only the ecommerce service). Subclasses immediately follow for each backend, which inherit from TestCase and define methods needed to customize test parameters, and patch the appropriate checkout method. @@ -1536,7 +1542,7 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase, TestVerification ) @patch('lms.djangoapps.verify_student.views.log.error') @patch('sailthru.sailthru_client.SailthruClient.send') - def test_passed_status_template(self, mock_sailthru_send, mock_log_error): + def test_passed_status_template(self, _mock_sailthru_send, _mock_log_error): """ Test for verification passed. """ @@ -1611,7 +1617,7 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase, TestVerification ) @patch('lms.djangoapps.verify_student.views.log.error') @patch('sailthru.sailthru_client.SailthruClient.send') - def test_failed_status_template(self, mock_sailthru_send, mock_log_error): + def test_failed_status_template(self, _mock_sailthru_send, _mock_log_error): """ Test for failed verification. """ @@ -1738,7 +1744,7 @@ class TestReverifyView(TestVerificationBase): """ # User has submitted a verification attempt, but Software Secure has not yet responded - attempt = self.create_and_submit_attempt_for_user(self.user) + self.create_and_submit_attempt_for_user(self.user) # Can re-verify because an attempt has already been submitted. self._assert_can_reverify() diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index b332b50f7e..9be05a98f3 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -11,7 +11,6 @@ import six from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.staticfiles.storage import staticfiles_storage -from django.core.mail import send_mail from django.db import transaction from django.http import Http404, HttpResponse, HttpResponseBadRequest from django.shortcuts import redirect @@ -31,7 +30,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from course_modes.models import CourseMode -from edxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response from lms.djangoapps.commerce.utils import EcommerceService, is_account_activation_requirement_disabled from lms.djangoapps.verify_student.emails import send_verification_approved_email, send_verification_confirmation_email from lms.djangoapps.verify_student.image import InvalidImageData, decode_image_data @@ -50,7 +49,6 @@ from student.models import CourseEnrollment from track import segment from util.db import outer_atomic from util.json_request import JsonResponse -from verify_student.toggles import use_new_templates_for_id_verification_emails from xmodule.modulestore.django import modulestore from .services import IDVerificationService @@ -400,12 +398,7 @@ class PayAndVerifyView(View): verification_good_until = self._verification_valid_until(request.user) # get available payment processors - if relevant_course_mode.sku: - # transaction will be conducted via ecommerce service - processors = ecommerce_api_client(request.user).payment.processors.get() - else: - # transaction will be conducted using legacy shopping cart - processors = [settings.CC_PROCESSOR_NAME] + processors = ecommerce_api_client(request.user).payment.processors.get() # Render the top-level page context = { @@ -815,7 +808,7 @@ def create_order(request): # a stale js client, which expects a response containing only the 'payment_form_data' part of # the payment data result. payment_data = payment_data['payment_form_data'] - return HttpResponse(json.dumps(payment_data), content_type="application/json") + return JsonResponse(payment_data) class SubmitPhotosView(View): @@ -1137,7 +1130,6 @@ def results_callback(request): elif result == "FAIL": log.debug(u"Denying verification for %s", receipt_id) attempt.deny(json.dumps(reason), error_code=error_code) - status = "denied" reverify_url = '{}/id-verification'.format(settings.ACCOUNT_MICROFRONTEND_URL) verification_status_email_vars['reasons'] = reason verification_status_email_vars['reverify_url'] = reverify_url @@ -1156,7 +1148,6 @@ def results_callback(request): elif result == "SYSTEM FAIL": log.debug(u"System failure for %s -- resetting to must_retry", receipt_id) attempt.system_error(json.dumps(reason), error_code=error_code) - status = "error" log.error(u"Software Secure callback attempt for %s failed: %s", receipt_id, reason) else: log.error(u"Software Secure returned unknown result %s", result) diff --git a/lms/envs/bok_choy.yml b/lms/envs/bok_choy.yml index 03512c6a59..9cbb67e65a 100644 --- a/lms/envs/bok_choy.yml +++ b/lms/envs/bok_choy.yml @@ -47,10 +47,6 @@ CACHES: # Capture the console log via template includes, until webdriver supports log capture again CAPTURE_CONSOLE_LOG: True -CC_PROCESSOR: - CyberSource2: {ACCESS_KEY: abcd123, PROFILE_ID: edx, PURCHASE_ENDPOINT: /shoppingcart/payment_fake, - SECRET_KEY: abcd123} -CC_PROCESSOR_NAME: CyberSource2 CELERY_BROKER_HOSTNAME: localhost CELERY_BROKER_PASSWORD: celery CELERY_BROKER_TRANSPORT: amqp @@ -120,7 +116,6 @@ FEATURES: ENABLE_COURSE_DISCOVERY: true ENABLE_DISCUSSION_SERVICE: true ENABLE_GRADE_DOWNLOADS: true - ENABLE_PAYMENT_FAKE: true ENABLE_SPECIAL_EXAMS: true ENABLE_THIRD_PARTY_AUTH: true ENABLE_VERIFIED_CERTIFICATES: true diff --git a/lms/envs/bok_choy_docker.yml b/lms/envs/bok_choy_docker.yml index d1218e70ff..30f168a65b 100644 --- a/lms/envs/bok_choy_docker.yml +++ b/lms/envs/bok_choy_docker.yml @@ -32,10 +32,6 @@ CACHES: KEY_FUNCTION: util.memcache.safe_key KEY_PREFIX: integration_static_files LOCATION: ['edx.devstack.memcached:11211'] -CC_PROCESSOR: - CyberSource2: {ACCESS_KEY: abcd123, PROFILE_ID: edx, PURCHASE_ENDPOINT: /shoppingcart/payment_fake, - SECRET_KEY: abcd123} -CC_PROCESSOR_NAME: CyberSource2 CELERY_BROKER_HOSTNAME: localhost CELERY_BROKER_PASSWORD: celery CELERY_BROKER_TRANSPORT: amqp @@ -85,7 +81,7 @@ FEATURES: {ALLOW_AUTOMATED_SIGNUPS: true, AUTOMATIC_AUTH_FOR_TESTING: true, AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING: true, CERTIFICATES_HTML_VIEW: true, CERTIFICATES_INSTRUCTOR_GENERATION: true, CUSTOM_COURSES_EDX: true, ENABLE_COURSE_DISCOVERY: true, ENABLE_DISCUSSION_SERVICE: true, ENABLE_GRADE_DOWNLOADS: true, - ENABLE_PAYMENT_FAKE: true, ENABLE_SPECIAL_EXAMS: true, ENABLE_THIRD_PARTY_AUTH: true, + ENABLE_SPECIAL_EXAMS: true, ENABLE_THIRD_PARTY_AUTH: true, ENABLE_VERIFIED_CERTIFICATES: true, EXPOSE_CACHE_PROGRAMS_ENDPOINT: true, MODE_CREATION_FOR_TESTING: true, PREVIEW_LMS_BASE: 'preview.localhost:8003', RESTRICT_AUTOMATIC_AUTH: false, SHOW_HEADER_LANGUAGE_SELECTOR: true} GITHUB_REPO_ROOT: '** OVERRIDDEN **' diff --git a/lms/envs/common.py b/lms/envs/common.py index 1499d82e2b..96491ce500 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -285,15 +285,6 @@ FEATURES = { # .. toggle_warnings: The login MFE domain name should be listed in LOGIN_REDIRECT_WHITELIST. 'SKIP_EMAIL_VALIDATION': False, - # Toggle the availability of the shopping cart page - 'ENABLE_SHOPPING_CART': False, - - # Toggle storing detailed billing information - 'STORE_BILLING_INFO': False, - - # Enable flow for payments for course registration (DIFFERENT from verified student flow) - 'ENABLE_PAID_COURSE_REGISTRATION': False, - # .. toggle_name: ENABLE_COSMETIC_DISPLAY_PRICE # .. toggle_implementation: DjangoSetting # .. toggle_default: False @@ -899,9 +890,6 @@ CONTEXT_PROCESSORS = [ # Hack to get required link URLs to password reset templates 'edxmako.shortcuts.marketing_link_context_processor', - # Shoppingcart processor (detects if request.user has a cart) - 'shoppingcart.context_processor.user_has_cart_context_processor', - # Timezone processor (sends language and time_zone preference) 'lms.djangoapps.courseware.context_processor.user_timezone_locale_prefs', @@ -1575,24 +1563,9 @@ EMBARGO_SITE_REDIRECT_URL = None ##### shoppingcart Payment ##### PAYMENT_SUPPORT_EMAIL = 'billing@example.com' -##### Using cybersource by default ##### - -CC_PROCESSOR_NAME = 'CyberSource2' -CC_PROCESSOR = { - 'CyberSource2': { - "PURCHASE_ENDPOINT": '', - "SECRET_KEY": '', - "ACCESS_KEY": '', - "PROFILE_ID": '', - } -} - # Setting for PAID_COURSE_REGISTRATION, DOES NOT AFFECT VERIFIED STUDENTS PAID_COURSE_REGISTRATION_CURRENCY = ['usd', '$'] -# Members of this group are allowed to generate payment reports -PAYMENT_REPORT_GENERATOR_GROUP = 'shoppingcart_report_access' - ################################# EdxNotes config ######################### # Configure the LMS to use our stub EdxNotes implementation diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 160fb25237..93aa3159e3 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -137,17 +137,6 @@ WEBPACK_CONFIG_PATH = 'webpack.dev.config.js' ########################### VERIFIED CERTIFICATES ################################# FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True -FEATURES['ENABLE_PAYMENT_FAKE'] = True - -CC_PROCESSOR_NAME = 'CyberSource2' -CC_PROCESSOR = { - 'CyberSource2': { - "PURCHASE_ENDPOINT": '/shoppingcart/payment_fake/', - "SECRET_KEY": 'abcd123', - "ACCESS_KEY": 'abcd123', - "PROFILE_ID": 'edx', - } -} ########################### External REST APIs ################################# FEATURES['ENABLE_OAUTH2_PROVIDER'] = True @@ -223,9 +212,6 @@ SEARCH_SKIP_ENROLLMENT_START_DATE_FILTERING = True ########################## Shopping cart ########################## -FEATURES['ENABLE_SHOPPING_CART'] = True -FEATURES['STORE_BILLING_INFO'] = True -FEATURES['ENABLE_PAID_COURSE_REGISTRATION'] = True FEATURES['ENABLE_COSMETIC_DISPLAY_PRICE'] = True ######################### Program Enrollments ##################### diff --git a/lms/envs/devstack_decentralized.py b/lms/envs/devstack_decentralized.py index df6ac65428..1790e0eee9 100644 --- a/lms/envs/devstack_decentralized.py +++ b/lms/envs/devstack_decentralized.py @@ -103,17 +103,6 @@ WEBPACK_CONFIG_PATH = 'webpack.dev.config.js' ########################### VERIFIED CERTIFICATES ################################# FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True -FEATURES['ENABLE_PAYMENT_FAKE'] = True - -CC_PROCESSOR_NAME = 'CyberSource2' -CC_PROCESSOR = { - 'CyberSource2': { - "PURCHASE_ENDPOINT": '/shoppingcart/payment_fake/', - "SECRET_KEY": 'abcd123', - "ACCESS_KEY": 'abcd123', - "PROFILE_ID": 'edx', - } -} ########################### External REST APIs ################################# FEATURES['ENABLE_OAUTH2_PROVIDER'] = True @@ -189,9 +178,6 @@ SEARCH_SKIP_ENROLLMENT_START_DATE_FILTERING = True ########################## Shopping cart ########################## -FEATURES['ENABLE_SHOPPING_CART'] = True -FEATURES['STORE_BILLING_INFO'] = True -FEATURES['ENABLE_PAID_COURSE_REGISTRATION'] = True FEATURES['ENABLE_COSMETIC_DISPLAY_PRICE'] = True ######################### Program Enrollments ##################### diff --git a/lms/envs/test.py b/lms/envs/test.py index bbf6946593..6a2055cede 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -74,8 +74,6 @@ FEATURES['ENABLE_DISCUSSION_SERVICE'] = False FEATURES['ENABLE_SERVICE_STATUS'] = True -FEATURES['ENABLE_SHOPPING_CART'] = True - FEATURES['ENABLE_VERIFIED_CERTIFICATES'] = True # Toggles embargo on for testing @@ -277,27 +275,6 @@ OAUTH_ENFORCE_SECURE = False FEATURES['ENABLE_MOBILE_REST_API'] = True FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True -###################### Payment ##############################3 -# Enable fake payment processing page -FEATURES['ENABLE_PAYMENT_FAKE'] = True - -# Configure the payment processor to use the fake processing page -# Since both the fake payment page and the shoppingcart app are using -# the same settings, we can generate this randomly and guarantee -# that they are using the same secret. -RANDOM_SHARED_SECRET = ''.join( - choice(ascii_letters + digits + punctuation) - for x in range(250) -) - -CC_PROCESSOR_NAME = 'CyberSource2' -CC_PROCESSOR['CyberSource2']['SECRET_KEY'] = RANDOM_SHARED_SECRET -CC_PROCESSOR['CyberSource2']['ACCESS_KEY'] = "0123456789012345678901" -CC_PROCESSOR['CyberSource2']['PROFILE_ID'] = "edx" -CC_PROCESSOR['CyberSource2']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" - -FEATURES['STORE_BILLING_INFO'] = True - ########################### SYSADMIN DASHBOARD ################################ FEATURES['ENABLE_SYSADMIN_DASHBOARD'] = True GIT_REPO_DIR = TEST_ROOT / "course_repos" diff --git a/lms/static/js/verify_student/views/make_payment_step_view.js b/lms/static/js/verify_student/views/make_payment_step_view.js index 3d2ff9ff4f..97083a12ac 100644 --- a/lms/static/js/verify_student/views/make_payment_step_view.js +++ b/lms/static/js/verify_student/views/make_payment_step_view.js @@ -146,9 +146,8 @@ var edx = edx || {}; .attr('aria-disabled', !isEnabled); }, - // This function invokes the create_order endpoint. It will either create an order in - // the lms' shoppingcart or a basket in Otto, depending on which backend the request course - // mode is configured to use. In either case, the checkout process will be triggered, + // This function invokes the create_order endpoint. It will create an order in + // a basket in Otto. The checkout process will be triggered, // and the expected response will consist of an appropriate payment processor endpoint for // redirection, along with parameters to be passed along in the request. createOrder: function(event) { diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 6a0e1a78b6..b3de5ad3d5 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -37,7 +37,6 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG cert_name_long = course_overview.cert_name_long if cert_name_long == "": cert_name_long = settings.CERT_NAME_LONG - billing_email = settings.PAYMENT_SUPPORT_EMAIL is_course_expired = hasattr(show_courseware_link, 'error_code') and show_courseware_link.error_code == 'audit_expired' %> diff --git a/lms/templates/shoppingcart/billing_details.html b/lms/templates/shoppingcart/billing_details.html deleted file mode 100644 index d929cf5f9e..0000000000 --- a/lms/templates/shoppingcart/billing_details.html +++ /dev/null @@ -1,125 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="shopping_cart_flow.html" /> -<%! -from openedx.core.djangolib.js_utils import js_escaped_string -from django.utils.translation import ugettext as _ -from django.urls import reverse -%> - -<%block name="billing_details_highlight">
  • ${_('Billing Details')}
  • -<%block name="confirmation_highlight"> - -<%block name="custom_content"> -
    - % if shoppingcart_items: -
    -

    ${_('You can proceed to payment at any point in time. Any additional information you provide will be included in your receipt.')}

    -
    -
    -

    ${_('Purchasing Organizational Details')}

    -
    -
    -
    -
    -

    ${_('Organization Contact')}

    -
    -
    -
    -
    -

    ${_('Additional Receipt Recipient')}

    -
    - - -
    -
    - - - -
    -
    -
    -
    -
    - ${_('Total')}: ${currency_symbol}${"{0:0.2f}".format(amount)} ${currency.upper()} -
    -
    -
    - -
    - ${form_html | n, decode.utf8} -

    - ${_('If no additional billing details are populated the payment confirmation will be sent to the user making the purchase.')} -

    -
    -
    -
    ${_('Payment processing occurs on a separate secure site.')}
    - -
    - %else: -
    -

    ${_('Your Shopping cart is currently empty.')}

    - ${_('View Courses')} -
    - %endif -
    - - - diff --git a/lms/templates/shoppingcart/cybersource_form.html b/lms/templates/shoppingcart/cybersource_form.html deleted file mode 100644 index ee9e6858a6..0000000000 --- a/lms/templates/shoppingcart/cybersource_form.html +++ /dev/null @@ -1,9 +0,0 @@ -<%page expression_filter="h"/> -<%! from django.utils.translation import ugettext as _ %> - - % for pk, pv in params.items(): - - % endfor - - - diff --git a/lms/templates/shoppingcart/download_report.html b/lms/templates/shoppingcart/download_report.html deleted file mode 100644 index 3f7c4bf91f..0000000000 --- a/lms/templates/shoppingcart/download_report.html +++ /dev/null @@ -1,61 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="../main.html" /> -<%! -from django.utils.translation import ugettext as _ -from django.urls import reverse -from django.conf import settings -%> - -<%block name="pagetitle">${_("Download CSV Reports")} - - -
    -

    ${_("Download CSV Data")}

    - % if date_fmt_error: -
    - ${_("There was an error in your date input. It should be formatted as YYYY-MM-DD")} -
    - % endif -
    - %if ("itemized_purchase_report" or "refund_report") in settings.FEATURES['ENABLED_PAYMENT_REPORTS']: -

    ${_("These reports are delimited by start and end dates.")}

    - - - - -
    - - %if "itemized_purchase_report" in settings.FEATURES['ENABLED_PAYMENT_REPORTS']: - -
    - %endif - - %if "refund_report" in settings.FEATURES['ENABLED_PAYMENT_REPORTS']: - -
    - %endif - -
    - %endif - - %if ("certificate_status" or "university_revenue_share") in settings.FEATURES['ENABLED_PAYMENT_REPORTS']: -

    ${_("These reports are delimited alphabetically by university name. i.e., generating a report with 'Start Letter' A and 'End Letter' C will generate reports for all universities starting with A, B, and C.")}

    - - - - - -
    - - %if "university_revenue_share" in settings.FEATURES['ENABLED_PAYMENT_REPORTS']: - -
    - %endif - - %if "certificate_status" in settings.FEATURES['ENABLED_PAYMENT_REPORTS']: - -
    - %endif - %endif -
    -
    diff --git a/lms/templates/shoppingcart/error.html b/lms/templates/shoppingcart/error.html deleted file mode 100644 index 042fbf406a..0000000000 --- a/lms/templates/shoppingcart/error.html +++ /dev/null @@ -1,11 +0,0 @@ -<%inherit file="../main.html" /> -<%! -from django.utils.translation import ugettext as _ -from django.urls import reverse -%> -<%block name="pagetitle">${_("Payment Error")} - -
    -

    ${_("There was an error processing your order!")}

    - ${error_html} -
    diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html deleted file mode 100644 index 07c1bb6db4..0000000000 --- a/lms/templates/shoppingcart/receipt.html +++ /dev/null @@ -1,369 +0,0 @@ -<%page expression_filter="h"/> -<%namespace name='static' file='/static_content.html'/> - -<%inherit file="shopping_cart_flow.html" /> -<%! -from django.utils.translation import ugettext as _ -from django.utils.translation import ungettext -from django.urls import reverse -from markupsafe import escape -from openedx.core.lib.courses import course_image_url -from openedx.core.djangolib.markup import HTML, Text -%> - -<%block name="billing_details_highlight"> -% if order_type == 'business': -
  • ${_('Billing Details')}
  • -%endif - - -<%block name="confirmation_highlight">class="active" - -<%block name="custom_content"> -
    -
    -
    -
    - <% 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.")}.

    - - - - - - - - - % for reg_code_info in reg_code_info_list: - - - - % if reg_code_info['is_redeemed']: - - % else: - - % endif - - - % endfor - -
    ${_("Course Name")}${_("Enrollment Code")}${_("Enrollment Link")}${_("Status")}
    ${reg_code_info['course_name']}${reg_code_info['code']}${reg_code_info['redemption_url']}${reg_code_info['redemption_url']} - % if reg_code_info['is_redeemed']: - ${_("Used")} - % elif not reg_code_info['is_valid']: - ${_("Invalid")} - % else: - ${_("Available")} - % endif -
    - %endif -
    -

    ${_('Invoice')} #${order.id}${_('Date of purchase')}: ${order_purchase_date} ${_('Print Receipt')} -

    -
    - % if order.total_cost > 0: -
    -

    ${_("Billed To Details")}:

    - -
    - % if order_type == 'business': -
    -
    -

    - ${_('Company Name')}: - - % if order.company_name: - ${order.company_name} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Purchase Order Number')}: - - % if order.customer_reference_number: - ${order.customer_reference_number} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Company Contact Name')}: - - % if order.company_contact_name: - ${order.company_contact_name} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Company Contact Email')}: - - % if order.company_contact_email: - ${order.company_contact_email} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Recipient Name')}: - - % if order.recipient_name: - ${order.recipient_name} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Recipient Email')}: - - % if order.recipient_email: - ${order.recipient_email} - % else: - ${_('N/A')} - % endif - -

    -
    -
    - %endif -
    -
    -

    - ${_('Card Type')}: - - % if order.bill_to_cardtype: - ${order.bill_to_cardtype} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Credit Card Number')}: - - % if order.bill_to_ccnum: - ${order.bill_to_ccnum} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Name')}: - - % if order.bill_to_first or order.bill_to_last: - ${order.bill_to_first} ${order.bill_to_last} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Address 1')}: - - % if order.bill_to_street1: - ${order.bill_to_street1} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Address 2')}: - - % if order.bill_to_street2: - ${order.bill_to_street2} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('City')}: - - % if order.bill_to_city: - ${order.bill_to_city} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('State')}: - - % if order.bill_to_state: - ${order.bill_to_state} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -

    - ${_('Country')}: - - % if order.bill_to_country: - ${order.bill_to_country} - % else: - ${_('N/A')} - % endif - -

    -
    -
    -
    -
    - % endif - - % for item, course in shoppingcart_items: - % if loop.index > 0: -
    - %endif -
    -
    -
    - ${course.display_number_with_default} ${course.display_name_with_default} Image -
    -
    - -

    - ${_('Registration for:')} - ${course.display_name} -

    -
    -
    - % if item.status == "purchased": -
    - % if item.is_discounted: -
    ${_('Price per student:')} ${currency_symbol}${"{0:0.2f}".format(item.list_price)} -
    -
    ${_('Discount Applied:')} ${currency_symbol}${"{0:0.2f}".format(item.unit_cost)}
    - % else: -
    ${_('Price per student:')} ${currency_symbol}${"{0:0.2f}".format(item.unit_cost)}
    - % endif -
    -
    -
    - -
    - ${item.qty} -
    -
    -
    - % elif item.status == "refunded": -
    - % if item.is_discounted: -
    ${_('Price per student:')} ${currency_symbol}${"{0:0.2f}".format(item.list_price)} -
    -
    ${_('Discount Applied:')} ${currency_symbol}${"{0:0.2f}".format(item.unit_cost)} -
    - % else: -
    ${_('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')}" /> -
    -
    - 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 name="content"> -
    -
    - -
    -
    - ${_( -
    -
    -
    - ${_("Confirm your enrollment for: {span_start}course dates{span_end}").format( - span_start='', - span_end='' - )} -
    -
    -
    -

    - ${_("{course_name}").format(course_name=course.display_name) | h} -

    -
    -
    -
    -

    - % 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: -
    - - -
    - %endif - %endif -
    -
    -
    - diff --git a/lms/templates/shoppingcart/registration_code_redemption.html b/lms/templates/shoppingcart/registration_code_redemption.html deleted file mode 100644 index a9a6d42a20..0000000000 --- a/lms/templates/shoppingcart/registration_code_redemption.html +++ /dev/null @@ -1,100 +0,0 @@ -<%page expression_filter="h"/> -<%! -from django.utils.translation import ugettext as _ -from openedx.core.djangolib.markup import HTML, Text -from django.urls import reverse -from openedx.core.lib.courses import course_image_url -%> -<%inherit file="../main.html" /> -<%namespace name='static' file='/static_content.html'/> -<%block name="pagetitle">${_("Confirm Enrollment")} - -<%block name="content"> -
    -
    - -
    -
    - ${_( -
    -
    -
    - ${Text(_("Confirm your enrollment for: {span_start}course dates{span_end}")).format( - span_start=HTML(''), - span_end=HTML('') - )} -
    -
    -
    -

    - ${course.display_name} -

    -
    -
    -
    -

    - % 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 -
    -
    -
    - 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" - -<%! -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': -
  • ${_('Billing Details')}
  • - % else: - - % endif - - - % if is_course_enrollment_closed: -

    ${expiry_message}

    - % endif - <% - discount_applied = False - order_type = 'personal' - %> - -
    - % for item, course in shoppingcart_items: - <% - ## Translators: currency_symbol is a symbol indicating type of currency, ex "$". - ## This string would look like this when all variables are in: - ## "$500.00" - unit_cost = _( - "{currency_symbol}{price}" - ).format( - currency_symbol=currency_symbol, - price="{0:0.2f}".format(item.unit_cost) - ) - %> - % if loop.index > 0 : -
    - %endif - % if item.order.order_type == 'business': - <% order_type = 'business' %> - %endif -
    -
    -
    - ${course.display_number_with_default} ${course.display_name_with_default} ${_('Cover Image')} -
    -
    - ## Translators: "Registration for:" is followed by a course name -

    - ${_('Registration for:')} - ${ course.display_name } -

    -
    -
    -
    - % if item.is_discounted: - <% discount_applied = True %> -
    ${_('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.')} -

    -
    - - % 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))} -

    -
    - - %endif -
    -
    -
    - % else: -
    -
    -

    ${_('Your Shopping cart is currently empty.')}

    - % if is_course_enrollment_closed: -

    ${expiry_message}

    - % endif - ${_('View Courses')} -
    -
    - % endif -
    -
    -
    - -
    - - diff --git a/lms/templates/shoppingcart/shopping_cart_flow.html b/lms/templates/shoppingcart/shopping_cart_flow.html deleted file mode 100644 index d04946edc0..0000000000 --- a/lms/templates/shoppingcart/shopping_cart_flow.html +++ /dev/null @@ -1,29 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="../main.html" /> -<%namespace name='static' file='/static_content.html'/> -<%! -from django.utils.translation import ugettext as _ -from django.conf import settings -%> -<%block name="pagetitle">${_("Shopping cart")} -<%block name="headextra"> - - -<%block name="bodyextra"> - -
    -
    -

    ${_("{platform_name} - Shopping Cart").format(platform_name=static.get_platform_name())}

    - % if shoppingcart_items: -
      -
    1. >${_('Review')}
    2. - <%block name="billing_details_highlight"/> -
    3. >${_('Payment')}
    4. -
    5. >${_('Confirmation')}
    6. -
    - %endif -
    -
    -<%block name="custom_content"/> - - diff --git a/lms/templates/shoppingcart/test/fake_payment_error.html b/lms/templates/shoppingcart/test/fake_payment_error.html deleted file mode 100644 index 2082faf3f0..0000000000 --- a/lms/templates/shoppingcart/test/fake_payment_error.html +++ /dev/null @@ -1,10 +0,0 @@ -<%page expression_filter="h"/> - - - Payment Error - - -

    An error occurred while you submitted your order. - If you are trying to make a purchase, please contact the merchant.

    - - diff --git a/lms/templates/shoppingcart/test/fake_payment_page.html b/lms/templates/shoppingcart/test/fake_payment_page.html deleted file mode 100644 index e3a237a7c1..0000000000 --- a/lms/templates/shoppingcart/test/fake_payment_page.html +++ /dev/null @@ -1,26 +0,0 @@ -<%page expression_filter="h"/> - -Payment Form - - -

    Payment page

    - -
    - % for name, value in post_params_success.items(): - - % endfor - -
    - -
    - % for name, value in post_params_decline.items(): - - % endfor - -
    - - - - - - diff --git a/lms/urls.py b/lms/urls.py index e6620b7d9c..5e99a65b31 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -770,11 +770,6 @@ if configuration_helpers.get_value('ENABLE_BULK_ENROLLMENT_VIEW', settings.FEATU url(r'^api/bulk_enroll/v1/', include('bulk_enroll.urls')), ] -# Shopping cart -urlpatterns += [ - url(r'^shoppingcart/', include('shoppingcart.urls')), -] - # Course goals urlpatterns += [ url(r'^api/course_goals/', include(('lms.djangoapps.course_goals.urls', 'lms.djangoapps.course_goals'), diff --git a/requirements/edx/base.in b/requirements/edx/base.in index 69a6c21f26..360af1a182 100644 --- a/requirements/edx/base.in +++ b/requirements/edx/base.in @@ -115,7 +115,6 @@ nodeenv # Utility for managing Node.js environments; oauthlib # OAuth specification support for authenticating via LTI or other Open edX services openedx-calc # Library supporting mathematical calculations for Open edX ora2 -pdfminer.six # Used in shoppingcart for extracting/parsing pdf text piexif # Exif image metadata manipulation, used in the profile_images app Pillow # Image manipulation library; used for course assets, profile images, invoice PDFs, etc. py2neo<4.0.0 # Used to communicate with Neo4j, which is used internally for modulestore inspection diff --git a/scripts/py2_to_py3_convert_and_create_pr.sh b/scripts/py2_to_py3_convert_and_create_pr.sh deleted file mode 100644 index 7eda5a9c42..0000000000 --- a/scripts/py2_to_py3_convert_and_create_pr.sh +++ /dev/null @@ -1,75 +0,0 @@ -# Prequisites -# 1) You must have devstack up and running -# 2) You must have hub installed (brew install hub) -# 3) You must have a publickey set up with github (so hub can make your PR) -# 4) You must have run the script here https://github.com/edx/testeng-ci/blob/master/scripts/create_incr_tickets.py -# in order to generate an input file used for bulk creation of INCR tickets - -# How To Run -# 1) Have devstack up and running (via `make dev.up` in your devstack repo) -# 2) On the command line, go into your edx-platform repo checkout -# 3) Make sure you are on the master branchof edx-platform with no changes -# 4) Run this script from the root of the repo, handing it your username, and -# the path to the input file generated in the prerequisites. -# Example usage -# ./scripts/py2_to_py3_convert_and_create_pr.sh cpappas ~/testeng-ci/scripts/output.csv - -help_text="\nUsage: ./scripts/py2_to_py3_convert_and_create_pr.sh \n"; -help_text+="Example: ./scripts/py2_to_py3_convert_and_create_pr.sh cpappas ~/testeng-ci/scripts/output.csv\n\n"; - -for i in "$@" ; do - if [[ $i == "--help" ]] ; then - printf "$help_text"; - exit 0; - fi -done - -if [[ $# -lt 2 ]]; then - printf "$help_text"; - exit 0; -fi - -# the github user account that will be used to create these PRs -github_user="$1"; -# filepath of the csv file -input_file="$2"; - - -while read line; do - - # Given the following line from the input file: - # INCR-233,False,14,lms/djangoapps/shoppingcart/management:lms/djangoapps/shoppingcart/tests - # the ticket number should be: 'INCR-233' - # the directories to modernize should be "lms/djangoapps/shoppingcart/management lms/djangoapps/shoppingcart/tests" - ticket_number=`echo $line |cut -d',' -f1`; - directories=`echo $line |cut -d',' -f4 |tr ':' ' '`; - branch_name="$github_user/$ticket_number"; - - # make sure we are based on master for each consecutive new pull request we create - git checkout master; - - # create a new branch for this INCR ticket - git checkout -b $branch_name || { printf "\n\nERROR: could not check out branch with name: $branch_name\n\n"; exit 1; } - - # run python modernize on the specified directories - docker exec -t edx.devstack.lms bash -c "source /edx/app/edxapp/edxapp_env && cd /edx/app/edxapp/edx-platform/ && python-modernize -w $directories" - - # commit the changes from running python-modernize - git add $directories || { printf "\n\nERROR: Could not 'git add' directory $directories\n\n"; exit 1; } - git commit -m "run python modernize" || { printf "\n\nERROR: Could not commit files to $branch_name\n\n"; exit 1; } - - # run isort on the specified directories - docker exec -t edx.devstack.lms bash -c "source /edx/app/edxapp/edxapp_env && cd /edx/app/edxapp/edx-platform/ && isort -rc $directories" - - # commit the changes from running isort - git add $directories || { printf "\n\nERROR: Could not 'git add' directory $directories\n\n"; exit 1; } - git commit -m "run isort" || { printf "\n\nERROR: Could not commit files to $branch_name\n\n"; exit 1; } - - git push origin "$branch_name" || { printf "\n\nERROR: Could not push branch to remote. If you are outside of the edX organization, you might consider first forking the repo, and then running this command to create a PR from within that checkout.\n\n"; exit 1; } - - hub pull-request -m "$ticket_number" || { printf "\n\nERROR: Did not successfully create PR for this conversion\n\n"; } - - # avoid hitting the Github rate limit - sleep 2 - -done < $input_file diff --git a/sys_path_hacks/lms/shoppingcart/admin.py b/sys_path_hacks/lms/shoppingcart/admin.py deleted file mode 100644 index 6fab5fbda8..0000000000 --- a/sys_path_hacks/lms/shoppingcart/admin.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.admin') - -from lms.djangoapps.shoppingcart.admin import * diff --git a/sys_path_hacks/lms/shoppingcart/api.py b/sys_path_hacks/lms/shoppingcart/api.py deleted file mode 100644 index addbd484c2..0000000000 --- a/sys_path_hacks/lms/shoppingcart/api.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.api') - -from lms.djangoapps.shoppingcart.api import * diff --git a/sys_path_hacks/lms/shoppingcart/context_processor.py b/sys_path_hacks/lms/shoppingcart/context_processor.py deleted file mode 100644 index 2c9b8d825e..0000000000 --- a/sys_path_hacks/lms/shoppingcart/context_processor.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.context_processor') - -from lms.djangoapps.shoppingcart.context_processor import * diff --git a/sys_path_hacks/lms/shoppingcart/decorators.py b/sys_path_hacks/lms/shoppingcart/decorators.py deleted file mode 100644 index 806bc3f1ca..0000000000 --- a/sys_path_hacks/lms/shoppingcart/decorators.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.decorators') - -from lms.djangoapps.shoppingcart.decorators import * diff --git a/sys_path_hacks/lms/shoppingcart/exceptions.py b/sys_path_hacks/lms/shoppingcart/exceptions.py deleted file mode 100644 index 61303f176c..0000000000 --- a/sys_path_hacks/lms/shoppingcart/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.exceptions') - -from lms.djangoapps.shoppingcart.exceptions import * diff --git a/sys_path_hacks/lms/shoppingcart/management/__init__.py b/sys_path_hacks/lms/shoppingcart/management/__init__.py deleted file mode 100644 index 5c47609baf..0000000000 --- a/sys_path_hacks/lms/shoppingcart/management/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.management') - -from lms.djangoapps.shoppingcart.management import * diff --git a/sys_path_hacks/lms/shoppingcart/management/commands/__init__.py b/sys_path_hacks/lms/shoppingcart/management/commands/__init__.py deleted file mode 100644 index 832f02c72f..0000000000 --- a/sys_path_hacks/lms/shoppingcart/management/commands/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.management.commands') - -from lms.djangoapps.shoppingcart.management.commands import * diff --git a/sys_path_hacks/lms/shoppingcart/management/commands/retire_order.py b/sys_path_hacks/lms/shoppingcart/management/commands/retire_order.py deleted file mode 100644 index 78c12edba4..0000000000 --- a/sys_path_hacks/lms/shoppingcart/management/commands/retire_order.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.management.commands.retire_order') - -from lms.djangoapps.shoppingcart.management.commands.retire_order import * diff --git a/sys_path_hacks/lms/shoppingcart/management/tests/__init__.py b/sys_path_hacks/lms/shoppingcart/management/tests/__init__.py deleted file mode 100644 index 51b9532df1..0000000000 --- a/sys_path_hacks/lms/shoppingcart/management/tests/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.management.tests') - -from lms.djangoapps.shoppingcart.management.tests import * diff --git a/sys_path_hacks/lms/shoppingcart/management/tests/test_retire_order.py b/sys_path_hacks/lms/shoppingcart/management/tests/test_retire_order.py deleted file mode 100644 index 7802d777d9..0000000000 --- a/sys_path_hacks/lms/shoppingcart/management/tests/test_retire_order.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.management.tests.test_retire_order') - -from lms.djangoapps.shoppingcart.management.tests.test_retire_order import * diff --git a/sys_path_hacks/lms/shoppingcart/models.py b/sys_path_hacks/lms/shoppingcart/models.py deleted file mode 100644 index 311d455c68..0000000000 --- a/sys_path_hacks/lms/shoppingcart/models.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.models') - -from lms.djangoapps.shoppingcart.models import * diff --git a/sys_path_hacks/lms/shoppingcart/processors/CyberSource2.py b/sys_path_hacks/lms/shoppingcart/processors/CyberSource2.py deleted file mode 100644 index 7e47665365..0000000000 --- a/sys_path_hacks/lms/shoppingcart/processors/CyberSource2.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.processors.CyberSource2') - -from lms.djangoapps.shoppingcart.processors.CyberSource2 import * diff --git a/sys_path_hacks/lms/shoppingcart/processors/__init__.py b/sys_path_hacks/lms/shoppingcart/processors/__init__.py deleted file mode 100644 index 3ae0bf001a..0000000000 --- a/sys_path_hacks/lms/shoppingcart/processors/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.processors') - -from lms.djangoapps.shoppingcart.processors import * diff --git a/sys_path_hacks/lms/shoppingcart/processors/exceptions.py b/sys_path_hacks/lms/shoppingcart/processors/exceptions.py deleted file mode 100644 index 1366f4b2f5..0000000000 --- a/sys_path_hacks/lms/shoppingcart/processors/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.processors.exceptions') - -from lms.djangoapps.shoppingcart.processors.exceptions import * diff --git a/sys_path_hacks/lms/shoppingcart/processors/helpers.py b/sys_path_hacks/lms/shoppingcart/processors/helpers.py deleted file mode 100644 index 02d4aa5b9e..0000000000 --- a/sys_path_hacks/lms/shoppingcart/processors/helpers.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.processors.helpers') - -from lms.djangoapps.shoppingcart.processors.helpers import * diff --git a/sys_path_hacks/lms/shoppingcart/processors/tests/__init__.py b/sys_path_hacks/lms/shoppingcart/processors/tests/__init__.py deleted file mode 100644 index 0e7b62a1da..0000000000 --- a/sys_path_hacks/lms/shoppingcart/processors/tests/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.processors.tests') - -from lms.djangoapps.shoppingcart.processors.tests import * diff --git a/sys_path_hacks/lms/shoppingcart/processors/tests/test_CyberSource2.py b/sys_path_hacks/lms/shoppingcart/processors/tests/test_CyberSource2.py deleted file mode 100644 index 2898640025..0000000000 --- a/sys_path_hacks/lms/shoppingcart/processors/tests/test_CyberSource2.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.processors.tests.test_CyberSource2') - -from lms.djangoapps.shoppingcart.processors.tests.test_CyberSource2 import * diff --git a/sys_path_hacks/lms/shoppingcart/reports.py b/sys_path_hacks/lms/shoppingcart/reports.py deleted file mode 100644 index 2b42e3a9ce..0000000000 --- a/sys_path_hacks/lms/shoppingcart/reports.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.reports') - -from lms.djangoapps.shoppingcart.reports import * diff --git a/sys_path_hacks/lms/shoppingcart/tests/__init__.py b/sys_path_hacks/lms/shoppingcart/tests/__init__.py deleted file mode 100644 index 1b1a906d85..0000000000 --- a/sys_path_hacks/lms/shoppingcart/tests/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.tests') - -from lms.djangoapps.shoppingcart.tests import * diff --git a/sys_path_hacks/lms/shoppingcart/tests/payment_fake.py b/sys_path_hacks/lms/shoppingcart/tests/payment_fake.py deleted file mode 100644 index 4df7cd0135..0000000000 --- a/sys_path_hacks/lms/shoppingcart/tests/payment_fake.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.tests.payment_fake') - -from lms.djangoapps.shoppingcart.tests.payment_fake import * diff --git a/sys_path_hacks/lms/shoppingcart/tests/test_context_processor.py b/sys_path_hacks/lms/shoppingcart/tests/test_context_processor.py deleted file mode 100644 index 2df7b0c896..0000000000 --- a/sys_path_hacks/lms/shoppingcart/tests/test_context_processor.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.tests.test_context_processor') - -from lms.djangoapps.shoppingcart.tests.test_context_processor import * diff --git a/sys_path_hacks/lms/shoppingcart/tests/test_models.py b/sys_path_hacks/lms/shoppingcart/tests/test_models.py deleted file mode 100644 index 10b460955e..0000000000 --- a/sys_path_hacks/lms/shoppingcart/tests/test_models.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.tests.test_models') - -from lms.djangoapps.shoppingcart.tests.test_models import * diff --git a/sys_path_hacks/lms/shoppingcart/tests/test_payment_fake.py b/sys_path_hacks/lms/shoppingcart/tests/test_payment_fake.py deleted file mode 100644 index 1e82f9fb54..0000000000 --- a/sys_path_hacks/lms/shoppingcart/tests/test_payment_fake.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.tests.test_payment_fake') - -from lms.djangoapps.shoppingcart.tests.test_payment_fake import * diff --git a/sys_path_hacks/lms/shoppingcart/tests/test_reports.py b/sys_path_hacks/lms/shoppingcart/tests/test_reports.py deleted file mode 100644 index 161f190511..0000000000 --- a/sys_path_hacks/lms/shoppingcart/tests/test_reports.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.tests.test_reports') - -from lms.djangoapps.shoppingcart.tests.test_reports import * diff --git a/sys_path_hacks/lms/shoppingcart/tests/test_views.py b/sys_path_hacks/lms/shoppingcart/tests/test_views.py deleted file mode 100644 index 2492b27de5..0000000000 --- a/sys_path_hacks/lms/shoppingcart/tests/test_views.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.tests.test_views') - -from lms.djangoapps.shoppingcart.tests.test_views import * diff --git a/sys_path_hacks/lms/shoppingcart/urls.py b/sys_path_hacks/lms/shoppingcart/urls.py deleted file mode 100644 index bb30fa5962..0000000000 --- a/sys_path_hacks/lms/shoppingcart/urls.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.urls') - -from lms.djangoapps.shoppingcart.urls import * diff --git a/sys_path_hacks/lms/shoppingcart/utils.py b/sys_path_hacks/lms/shoppingcart/utils.py deleted file mode 100644 index ce1ba57431..0000000000 --- a/sys_path_hacks/lms/shoppingcart/utils.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.utils') - -from lms.djangoapps.shoppingcart.utils import * diff --git a/sys_path_hacks/lms/shoppingcart/views.py b/sys_path_hacks/lms/shoppingcart/views.py deleted file mode 100644 index d5a7ad8435..0000000000 --- a/sys_path_hacks/lms/shoppingcart/views.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys_path_hacks.warn import warn_deprecated_import - -warn_deprecated_import('lms.djangoapps', 'shoppingcart.views') - -from lms.djangoapps.shoppingcart.views import *