Merge pull request #8478 from edx/afzaledx/MAYN-78
MAYN-78 Extend Coupon/Discount Reports
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -320,7 +320,7 @@ from microsite_configuration import microsite
|
||||
<div class="three-col">
|
||||
% if item.status == "purchased":
|
||||
<div class="col-1">
|
||||
% if item.list_price != None:
|
||||
% if item.is_discounted:
|
||||
<div class="price">${_('Price per student:')} <span class="line-through"> ${currency_symbol}${"{0:0.2f}".format(item.list_price)}</span>
|
||||
</div>
|
||||
<div class="price green">${_('Discount Applied:')} <span> ${currency_symbol}${"{0:0.2f}".format(item.unit_cost)} </span></div>
|
||||
@@ -338,7 +338,7 @@ from microsite_configuration import microsite
|
||||
</div>
|
||||
% elif item.status == "refunded":
|
||||
<div class="col-1">
|
||||
% if item.list_price != None:
|
||||
% if item.is_discounted:
|
||||
<div class="price">${_('Price per student:')} <span class="line-through"> ${currency_symbol}${"{0:0.2f}".format(item.list_price)}</span>
|
||||
</div>
|
||||
<div class="price green">${_('Discount Applied:')} <span><del> ${currency_symbol}${"{0:0.2f}".format(item.unit_cost)}
|
||||
|
||||
@@ -78,7 +78,7 @@ from django.utils.translation import ungettext
|
||||
<hr>
|
||||
<div class="three-col">
|
||||
<div class="col-1">
|
||||
% if item.list_price != None:
|
||||
% if item.is_discounted:
|
||||
<% discount_applied = True %>
|
||||
<div class="price">${_('Price per student:')}
|
||||
<span class="line-through">
|
||||
|
||||
Reference in New Issue
Block a user