diff --git a/lms/djangoapps/instructor/paidcourse_enrollment_report.py b/lms/djangoapps/instructor/paidcourse_enrollment_report.py index 1d319312d7..cc7697fb16 100644 --- a/lms/djangoapps/instructor/paidcourse_enrollment_report.py +++ b/lms/djangoapps/instructor/paidcourse_enrollment_report.py @@ -94,11 +94,7 @@ class PaidCourseEnrollmentReportProvider(BaseAbstractEnrollmentReportProvider): coupon_codes = ", ".join(coupon_codes) registration_code_used = 'N/A' - if coupon_redemption.exists(): - list_price = paid_course_reg_item.list_price - else: - list_price = paid_course_reg_item.unit_cost - + list_price = paid_course_reg_item.get_list_price() payment_amount = paid_course_reg_item.unit_cost coupon_codes_used = coupon_codes payment_status = paid_course_reg_item.status @@ -156,7 +152,7 @@ class PaidCourseEnrollmentReportProvider(BaseAbstractEnrollmentReportProvider): coupon_codes = [redemption.coupon.code for redemption in coupon_redemption] coupon_codes = ", ".join(coupon_codes) - list_price = order_item.list_price + list_price = order_item.get_list_price() payment_amount = order_item.unit_cost coupon_codes_used = coupon_codes payment_status = order_item.status diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 5527267670..fb697005e2 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -75,7 +75,8 @@ EXPECTED_CSV_HEADER = ( '"code","redeem_code_url","course_id","company_name","created_by","redeemed_by","invoice_id","purchaser",' '"customer_reference_number","internal_reference"' ) -EXPECTED_COUPON_CSV_HEADER = '"code","course_id","percentage_discount","code_redeemed_count","description"' +EXPECTED_COUPON_CSV_HEADER = '"Coupon Code","Course Id","% Discount","Description","Expiration Date",' \ + '"Is Active","Code Redeemed Count","Total Discounted Seats","Total Discounted Amount"' # ddt data for test cases involving reports REPORTS_DATA = ( @@ -4246,13 +4247,20 @@ class TestCourseRegistrationCodes(ModuleStoreTestCase): self.assertEqual(response.status_code, 200, response.content) # filter all the coupons for coupon in Coupon.objects.all(): - self.assertIn('"{code}","{course_id}","{discount}","0","{description}","{expiration_date}","True"'.format( - code=coupon.code, - course_id=coupon.course_id, - discount=coupon.percentage_discount, - description=coupon.description, - expiration_date=coupon.display_expiry_date - ), response.content) + self.assertIn( + '"{coupon_code}","{course_id}","{discount}","{description}","{expiration_date}","{is_active}",' + '"{code_redeemed_count}","{total_discounted_seats}","{total_discounted_amount}"'.format( + coupon_code=coupon.code, + course_id=coupon.course_id, + discount=coupon.percentage_discount, + description=coupon.description, + expiration_date=coupon.display_expiry_date, + is_active=coupon.is_active, + code_redeemed_count="0", + total_discounted_seats="0", + total_discounted_amount="0", + ), response.content + ) self.assertEqual(response['Content-Type'], 'text/csv') body = response.content.replace('\r', '') diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 13cb42ab3a..bb67bb373b 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -951,7 +951,6 @@ def get_sale_order_records(request, course_id): # pylint: disable=unused-argume ('company_name', 'Company Name'), ('company_contact_name', 'Company Contact Name'), ('company_contact_email', 'Company Contact Email'), - ('total_amount', 'Total Amount'), ('logged_in_username', 'Login Username'), ('logged_in_email', 'Login User Email'), ('purchase_time', 'Date of Sale'), @@ -967,8 +966,11 @@ def get_sale_order_records(request, course_id): # pylint: disable=unused-argume ('order_type', 'Order Type'), ('status', 'Order Item Status'), ('coupon_code', 'Coupon Code'), - ('unit_cost', 'Unit Price'), ('list_price', 'List Price'), + ('unit_cost', 'Unit Price'), + ('quantity', 'Quantity'), + ('total_discount', 'Total Discount'), + ('total_amount', 'Total Amount Paid'), ] db_columns = [x[0] for x in query_features] @@ -1198,11 +1200,22 @@ def get_coupon_codes(request, course_id): # pylint: disable=unused-argument coupons = Coupon.objects.filter(course_id=course_id) query_features = [ - 'code', 'course_id', 'percentage_discount', 'code_redeemed_count', 'description', 'expiration_date', 'is_active' + ('code', _('Coupon Code')), + ('course_id', _('Course Id')), + ('percentage_discount', _('% Discount')), + ('description', _('Description')), + ('expiration_date', _('Expiration Date')), + ('is_active', _('Is Active')), + ('code_redeemed_count', _('Code Redeemed Count')), + ('total_discounted_seats', _('Total Discounted Seats')), + ('total_discounted_amount', _('Total Discounted Amount')), ] - coupons_list = instructor_analytics.basic.coupon_codes_features(query_features, coupons) - header, data_rows = instructor_analytics.csvs.format_dictlist(coupons_list, query_features) - return instructor_analytics.csvs.create_csv_response('Coupons.csv', header, data_rows) + db_columns = [x[0] for x in query_features] + csv_columns = [x[1] for x in query_features] + + coupons_list = instructor_analytics.basic.coupon_codes_features(db_columns, coupons, course_id) + __, data_rows = instructor_analytics.csvs.format_dictlist(coupons_list, db_columns) + return instructor_analytics.csvs.create_csv_response('Coupons.csv', csv_columns, data_rows) @ensure_csrf_cookie diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 6bda306bbd..06a890c0d6 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -72,6 +72,7 @@ def sale_order_record_features(course_id, features): quantity = int(getattr(purchased_course, 'qty')) unit_cost = float(getattr(purchased_course, 'unit_cost')) + sale_order_dict.update({"quantity": quantity}) sale_order_dict.update({"total_amount": quantity * unit_cost}) sale_order_dict.update({"logged_in_username": purchased_course.order.user.username}) @@ -80,6 +81,13 @@ def sale_order_record_features(course_id, features): # Extracting OrderItem information of unit_cost, list_price and status order_item_dict = dict((feature, getattr(purchased_course, feature, None)) for feature in order_item_features) + + order_item_dict['list_price'] = purchased_course.get_list_price() + + sale_order_dict.update( + {"total_discount": (order_item_dict['list_price'] - order_item_dict['unit_cost']) * quantity} + ) + order_item_dict.update({"coupon_code": 'N/A'}) coupon_redemption = CouponRedemption.objects.select_related('coupon').filter(order_id=purchased_course.order_id) @@ -235,7 +243,7 @@ def list_may_enroll(course_key, features): return [extract_student(student, features) for student in may_enroll_and_unenrolled] -def coupon_codes_features(features, coupons_list): +def coupon_codes_features(features, coupons_list, course_id): """ Return list of Coupon Codes as dictionaries. @@ -254,13 +262,33 @@ def coupon_codes_features(features, coupons_list): coupon_features = [x for x in COUPON_FEATURES if x in features] coupon_dict = dict((feature, getattr(coupon, feature)) for feature in coupon_features) - coupon_dict['code_redeemed_count'] = coupon.couponredemption_set.filter( + coupon_redemptions = coupon.couponredemption_set.filter( order__status="purchased" - ).count() + ) - # we have to capture the redeemed_by value in the case of the downloading and spent registration + coupon_dict['code_redeemed_count'] = coupon_redemptions.count() + + seats_purchased_using_coupon = 0 + total_discounted_amount = 0 + for coupon_redemption in coupon_redemptions: + cart_items = coupon_redemption.order.orderitem_set.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) + for order_item in found_items: + seats_purchased_using_coupon += order_item.qty + discounted_amount_for_item = float( + order_item.list_price * order_item.qty) * (float(coupon.percentage_discount) / 100) + total_discounted_amount += discounted_amount_for_item + + coupon_dict['total_discounted_seats'] = seats_purchased_using_coupon + coupon_dict['total_discounted_amount'] = total_discounted_amount + + # We have to capture the redeemed_by value in the case of the downloading and spent registration # codes csv. In the case of active and generated registration codes the redeemed_by value will be None. - # They have not been redeemed yet + # They have not been redeemed yet coupon_dict['expiration_date'] = coupon.display_expiry_date coupon_dict['course_id'] = coupon_dict['course_id'].to_deprecated_string() diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 087219eb2a..b2180334a1 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -189,7 +189,7 @@ class TestCourseSaleRecordsAnalyticsBasic(ModuleStoreTestCase): self.assertEqual(sale_record['total_used_codes'], 0) self.assertEqual(sale_record['total_codes'], 5) - def test_sale_order_features(self): + def test_sale_order_features_with_discount(self): """ Test Order Sales Report CSV """ @@ -267,6 +267,78 @@ class TestCourseSaleRecordsAnalyticsBasic(ModuleStoreTestCase): self.assertEqual(sale_order_record['status'], item.status) self.assertEqual(sale_order_record['coupon_code'], coupon_redemption[0].coupon.code) + def test_sale_order_features_without_discount(self): + """ + Test Order Sales Report CSV + """ + query_features = [ + ('id', 'Order Id'), + ('company_name', 'Company Name'), + ('company_contact_name', 'Company Contact Name'), + ('company_contact_email', 'Company Contact Email'), + ('total_amount', 'Total Amount'), + ('total_codes', 'Total Codes'), + ('total_used_codes', 'Total Used Codes'), + ('logged_in_username', 'Login Username'), + ('logged_in_email', 'Login User Email'), + ('purchase_time', 'Date of Sale'), + ('customer_reference_number', 'Customer Reference Number'), + ('recipient_name', 'Recipient Name'), + ('recipient_email', 'Recipient Email'), + ('bill_to_street1', 'Street 1'), + ('bill_to_street2', 'Street 2'), + ('bill_to_city', 'City'), + ('bill_to_state', 'State'), + ('bill_to_postalcode', 'Postal Code'), + ('bill_to_country', 'Country'), + ('order_type', 'Order Type'), + ('status', 'Order Item Status'), + ('coupon_code', 'Coupon Code'), + ('unit_cost', 'Unit Price'), + ('list_price', 'List Price'), + ('codes', 'Registration Codes'), + ('course_id', 'Course Id'), + ('quantity', 'Quantity'), + ('total_discount', 'Total Discount'), + ('total_amount', 'Total Amount Paid'), + ] + # add the coupon code for the course + order = Order.get_cart_for_user(self.instructor) + order.order_type = 'business' + order.save() + order.add_billing_details( + company_name='Test Company', + company_contact_name='Test', + company_contact_email='test@123', + recipient_name='R1', recipient_email='', + customer_reference_number='PO#23' + ) + CourseRegCodeItem.add_to_order(order, self.course.id, 4) + order.purchase() + + # get the updated item + item = order.orderitem_set.all().select_subclasses()[0] + + db_columns = [x[0] for x in query_features] + sale_order_records_list = sale_order_record_features(self.course.id, db_columns) + + for sale_order_record in sale_order_records_list: + self.assertEqual(sale_order_record['recipient_email'], order.recipient_email) + self.assertEqual(sale_order_record['recipient_name'], order.recipient_name) + self.assertEqual(sale_order_record['company_name'], order.company_name) + self.assertEqual(sale_order_record['company_contact_name'], order.company_contact_name) + self.assertEqual(sale_order_record['company_contact_email'], order.company_contact_email) + self.assertEqual(sale_order_record['customer_reference_number'], order.customer_reference_number) + self.assertEqual(sale_order_record['unit_cost'], item.unit_cost) + # Make sure list price is not None and matches the unit price since no discount was applied. + self.assertIsNotNone(sale_order_record['list_price']) + self.assertEqual(sale_order_record['list_price'], item.unit_cost) + self.assertEqual(sale_order_record['status'], item.status) + self.assertEqual(sale_order_record['coupon_code'], 'N/A') + self.assertEqual(sale_order_record['total_amount'], item.unit_cost * item.qty) + self.assertEqual(sale_order_record['total_discount'], 0) + self.assertEqual(sale_order_record['quantity'], item.qty) + class TestCourseRegistrationCodeAnalyticsBasic(ModuleStoreTestCase): """ Test basic course registration codes analytics functions. """ @@ -340,7 +412,8 @@ class TestCourseRegistrationCodeAnalyticsBasic(ModuleStoreTestCase): def test_coupon_codes_features(self): query_features = [ - 'course_id', 'percentage_discount', 'code_redeemed_count', 'description', 'expiration_date' + 'course_id', 'percentage_discount', 'code_redeemed_count', 'description', 'expiration_date', + 'total_discounted_amount', 'total_discounted_seats' ] for i in range(10): coupon = Coupon( @@ -366,7 +439,7 @@ class TestCourseRegistrationCodeAnalyticsBasic(ModuleStoreTestCase): Q(expiration_date__gt=datetime.datetime.now(pytz.UTC)) | Q(expiration_date__isnull=True) ) - active_coupons_list = coupon_codes_features(query_features, active_coupons) + active_coupons_list = coupon_codes_features(query_features, active_coupons, self.course.id) self.assertEqual(len(active_coupons_list), len(active_coupons)) for active_coupon in active_coupons_list: self.assertEqual(set(active_coupon.keys()), set(query_features)) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index cb388a2c8a..84f58cf46a 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -220,9 +220,8 @@ class Order(models.Model): Reset the items price state in the user cart """ for item in self.orderitem_set.all(): # pylint: disable=no-member - if item.list_price: + if item.is_discounted: item.unit_cost = item.list_price - item.list_price = None item.save() def clear(self): @@ -300,19 +299,12 @@ class Order(models.Model): """ items_data = [] for item in order_items: - if item.list_price is not None: - discount_price = item.list_price - item.unit_cost - price = item.list_price - else: - discount_price = 0 - price = item.unit_cost - item_total = item.qty * item.unit_cost items_data.append({ 'item_description': item.pdf_receipt_display_name, 'quantity': item.qty, - 'list_price': price, - 'discount': discount_price, + 'list_price': item.get_list_price(), + 'discount': item.get_list_price() - item.unit_cost, 'item_total': item_total }) pdf_buffer = BytesIO() @@ -718,6 +710,23 @@ class OrderItem(TimeStampedModel): """ 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): """ @@ -1449,6 +1458,7 @@ class PaidCourseRegistration(OrderItem): 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 @@ -1602,6 +1612,7 @@ class CourseRegCodeItem(OrderItem): 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) @@ -1803,6 +1814,7 @@ class CertificateItem(OrderItem): 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) diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 447e9bf29f..b221b4f444 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -430,6 +430,40 @@ class OrderItemTest(TestCase): self.assertDictEqual({item.pk_with_subclass: set([])}, inst_dict) self.assertEquals(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 PaidCourseRegistrationTest(ModuleStoreTestCase): def setUp(self): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 75d480495f..eb7a797310 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -664,8 +664,10 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): for item in items: if item.id == reg_item.id: self.assertEquals(item.unit_cost, self.get_discount(self.cost)) + self.assertEquals(item.list_price, self.cost) elif item.id == cert_item.id: - self.assertEquals(item.list_price, None) + self.assertEquals(item.list_price, self.cost) + self.assertEquals(item.unit_cost, self.cost) # Delete the discounted item, corresponding coupon redemption should # be removed for that particular discounted item diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html index 8976ae06d8..6be626c5c7 100644 --- a/lms/templates/shoppingcart/receipt.html +++ b/lms/templates/shoppingcart/receipt.html @@ -320,7 +320,7 @@ from microsite_configuration import microsite
% if item.status == "purchased":
- % if item.list_price != None: + % 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)}
@@ -338,7 +338,7 @@ from microsite_configuration import microsite
% elif item.status == "refunded":
- % if item.list_price != None: + % 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)} diff --git a/lms/templates/shoppingcart/shopping_cart.html b/lms/templates/shoppingcart/shopping_cart.html index 8df06c14ed..bb47a47181 100644 --- a/lms/templates/shoppingcart/shopping_cart.html +++ b/lms/templates/shoppingcart/shopping_cart.html @@ -78,7 +78,7 @@ from django.utils.translation import ungettext
- % if item.list_price != None: + % if item.is_discounted: <% discount_applied = True %>
${_('Price per student:')}