Commit edd0b543 by Julia Hansbrough

Response to CR

parent 68174dc3
......@@ -580,6 +580,11 @@ class CourseEnrollment(models.Model):
courseenrollment__is_active=True
)
@classmethod
def enrollments_in(cls, course_id):
"""Return a queryset of CourseEnrollment for every active enrollment in the course."""
return cls.objects.filter(course_id=course_id, is_active=True,)
def activate(self):
"""Makes this `CourseEnrollment` record active. Saves immediately."""
self.update_enrollment(is_active=True)
......
......@@ -10,7 +10,6 @@ from boto.exception import BotoServerError # this is a super-class of SESError
from django.dispatch import receiver
from django.db import models
from django.db.models import Sum
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.mail import send_mail
......@@ -19,14 +18,11 @@ from django.utils.translation import ugettext as _
from django.db import transaction
from django.core.urlresolvers import reverse
from decimal import Decimal
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError
from course_modes.models import CourseMode
from courseware.courses import get_course_by_id
from edxmako.shortcuts import render_to_string
from student.views import course_from_id
from student.models import CourseEnrollment, unenroll_done
......@@ -34,8 +30,7 @@ from student.models import CourseEnrollment, unenroll_done
from verify_student.models import SoftwareSecurePhotoVerification
from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException,
AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportException,
ReportTypeDoesNotExistException)
AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportException)
log = logging.getLogger("shoppingcart")
......@@ -45,6 +40,8 @@ ORDER_STATUSES = (
('refunded', 'refunded'),
)
# we need a tuple to represent the primary key of various OrderItem subclasses
OrderItemSubclassPK = namedtuple('OrderItemSubclassPK', ['cls', 'pk']) # pylint: disable=C0103
......@@ -571,255 +568,3 @@ class CertificateItem(OrderItem):
"Please include your order number in your e-mail. "
"Please do NOT include your credit card information.").format(
billing_email=settings.PAYMENT_SUPPORT_EMAIL)
class Report(models.Model):
"""
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 get_query, csv_report_header_row, and csv_report_row.
"""
@classmethod
def initialize_report(cls, report_type):
"""
Creates the appropriate type of Report object based on the string report_type.
"""
if report_type == "refund_report":
return RefundReport()
elif report_type == "itemized_purchase_report":
return ItemizedPurchaseReport()
elif report_type == "university_revenue_share":
return UniversityRevenueShareReport()
elif report_type == "certificate_status":
return CertificateStatusReport()
else:
raise ReportTypeDoesNotExistException
def get_query(self, start_date, end_date):
"""
Performs any database queries necessary to obtain the data for the report.
"""
raise NotImplementedError
def csv_report_header_row(self):
"""
Returns the appropriate header based on the report type.
"""
raise NotImplementedError
def csv_report_row(self, item):
"""
Given the results of the query from get_query, this function generates a single row of a csv.
"""
raise NotImplementedError
@classmethod
def make_report(cls, report_type, filelike, start_date, end_date):
"""
Given the string report_type, a file object to write to, and start/end date bounds,
generates a CSV report of the appropriate type.
"""
report = cls.initialize_report(report_type)
items = report.get_query(start_date, end_date)
writer = unicodecsv.writer(filelike, encoding="utf-8")
writer.writerow(report.csv_report_header_row())
for item in items:
writer.writerow(report.csv_report_row(item))
class RefundReport(Report):
"""
Subclass of Report, used to generate Refund Reports for finance purposes.
"""
def get_query(self, start_date, end_date):
return CertificateItem.objects.filter(
status="refunded",
refund_requested_time__gte=start_date,
refund_requested_time__lt=end_date,
)
def csv_report_header_row(self):
return [
"Order Number",
"Customer Name",
"Date of Original Transaction",
"Date of Refund",
"Amount of Refund",
"Service Fees (if any)",
]
def csv_report_row(self, item):
return [
item.order_id,
item.user.get_full_name(),
item.fulfilled_time,
item.refund_requested_time, # TODO Change this torefund_fulfilled once we start recording that value
item.line_cost,
item.service_fee,
]
class ItemizedPurchaseReport(Report):
"""
Subclass of Report, used to generate itemized purchase reports.
"""
def get_query(self, start_date, end_date):
return OrderItem.objects.filter(
status="purchased",
fulfilled_time__gte=start_date,
fulfilled_time__lt=end_date,
).order_by("fulfilled_time")
def csv_report_header_row(self):
return [
"Purchase Time",
"Order ID",
"Status",
"Quantity",
"Unit Cost",
"Total Cost",
"Currency",
"Description",
"Comments"
]
def csv_report_row(self, item):
return [
item.fulfilled_time,
item.order_id, # pylint: disable=no-member
item.status,
item.qty,
item.unit_cost,
item.line_cost,
item.currency,
item.line_desc,
item.report_comments,
]
class CertificateStatusReport(Report):
"""
Subclass of Report, used to generate Certificate Status Reports for ed services.
"""
def get_query(self, start_date, end_date):
results = []
for course_id in settings.COURSE_LISTINGS['default']:
cur_course = get_course_by_id(course_id)
university = cur_course.org
course = cur_course.number + " " + cur_course.display_name # TODO add term (i.e. Fall 2013)?
enrollments = CourseEnrollment.objects.filter(course_id=course_id,
is_active=True,)
total_enrolled = enrollments.count()
audit_enrolled = enrollments.filter(mode="audit").count()
honor_enrolled = enrollments.filter(mode="honor").count()
# Since every verified enrollment has 1 and only 1 cert item, let's just query those
verified_enrollments = CertificateItem.objects.filter(course_id=course_id, mode="verified", status="purchased")
verified_enrolled = verified_enrollments.count()
gross_rev_temp = CertificateItem.objects.filter(course_id=course_id, mode="verified", status="purchased").aggregate(Sum('unit_cost'))
gross_rev = gross_rev_temp['unit_cost__sum']
gross_rev_over_min = gross_rev - (CourseMode.objects.get(course_id=course_id, mode_slug="verified").min_price * verified_enrolled)
refunded_enrollments = CertificateItem.objects.filter(course_id='course_id', mode="verified", status="refunded")
number_of_refunds = refunded_enrollments.count()
dollars_refunded_temp = refunded_enrollments.aggregate(Sum('unit_cost'))
if dollars_refunded_temp['unit_cost__sum'] is None:
dollars_refunded = Decimal(0.00)
else:
dollars_refunded = dollars_refunded_temp['unit_cost__sum']
result = [
university,
course,
total_enrolled,
audit_enrolled,
honor_enrolled,
verified_enrolled,
gross_rev,
gross_rev_over_min,
number_of_refunds,
dollars_refunded
]
results.append(result)
return results
def csv_report_header_row(self):
return [
"University",
"Course",
"Total Enrolled",
"Audit Enrollment",
"Honor Code Enrollment",
"Verified Enrollment",
"Gross Revenue",
"Gross Revenue over the Minimum",
"Number of Refunds",
"Dollars Refunded",
]
def csv_report_row(self, item):
return item
class UniversityRevenueShareReport(Report):
"""
Subclass of Report, used to generate University Revenue Share Reports for finance purposes.
"""
def get_query(self, start_date, end_date):
results = []
for course_id in settings.COURSE_LISTINGS['default']:
cur_course = get_course_by_id(course_id)
university = cur_course.org
course = cur_course.number + " " + cur_course.display_name
num_transactions = 0 # TODO clarify with billing what transactions are included in this (purchases? refunds? etc)
all_paid_certs = CertificateItem.objects.filter(course_id=course_id, status="purchased")
total_payments_collected_temp = all_paid_certs.aggregate(Sum('unit_cost'))
if total_payments_collected_temp['unit_cost__sum'] is None:
total_payments_collected = Decimal(0.00)
else:
total_payments_collected = total_payments_collected_temp['unit_cost__sum']
total_service_fees_temp = all_paid_certs.aggregate(Sum('service_fee'))
if total_service_fees_temp['service_fee__sum'] is None:
service_fees = Decimal(0.00)
else:
service_fees = total_service_fees_temp['service_fee__sum']
refunded_enrollments = CertificateItem.objects.filter(course_id=course_id, status="refunded")
num_refunds = refunded_enrollments.count()
amount_refunds_temp = refunded_enrollments.aggregate(Sum('unit_cost'))
if amount_refunds_temp['unit_cost__sum'] is None:
amount_refunds = Decimal(0.00)
else:
amount_refunds = amount_refunds_temp['unit_cost__sum']
result = [
university,
course,
num_transactions,
total_payments_collected,
service_fees,
num_refunds,
amount_refunds
]
results.append(result)
return results
def csv_report_header_row(self):
return [
"University",
"Course",
"Number of Transactions",
"Total Payments Collected",
"Service Fees (if any)",
"Number of Successful Refunds",
"Total Amount of Refunds",
]
def csv_report_row(self, item):
return item
from shoppingcart.models import CertificateItem, OrderItem
from django.db import models
from django.db.models import Sum
import unicodecsv
from django.conf import settings
from courseware.courses import get_course_by_id
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from decimal import Decimal
class Report(models.Model):
"""
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 get_report_data, csv_report_header_row, and csv_report_row.
"""
def get_report_data(self, start_date, end_date, start_letter=None, end_letter=None):
"""
Performs database queries necessary for the report. May return either a query result
or a list of lists, depending on the particular type of report--see Report subclasses
for sample implementations.
"""
raise NotImplementedError
def csv_report_header_row(self):
"""
Returns the appropriate header based on the report type.
"""
raise NotImplementedError
def csv_report_row(self, item):
"""
Given the results of get_report_data, this function generates a single row of a csv.
"""
raise NotImplementedError
def make_report(self, filelike, start_date, end_date, start_letter=None, end_letter=None):
"""
Given the string report_type, a file object to write to, and start/end date bounds,
generates a CSV report of the appropriate type.
"""
items = self.get_report_data(start_date, end_date, start_letter, end_letter)
writer = unicodecsv.writer(filelike, encoding="utf-8")
writer.writerow(self.csv_report_header_row())
for item in items:
writer.writerow(self.csv_report_row(item))
class RefundReport(Report):
"""
Subclass of Report, used to generate Refund Reports for finance purposes.
"""
def get_report_data(self, start_date, end_date, start_letter=None, end_letter=None):
return CertificateItem.objects.filter(
status="refunded",
refund_requested_time__gte=start_date,
refund_requested_time__lt=end_date,
)
def csv_report_header_row(self):
return [
"Order Number",
"Customer Name",
"Date of Original Transaction",
"Date of Refund",
"Amount of Refund",
"Service Fees (if any)",
]
def csv_report_row(self, item):
return [
item.order_id,
item.user.get_full_name(),
item.fulfilled_time,
item.refund_requested_time, # TODO Change this torefund_fulfilled once we start recording that value
item.line_cost,
item.service_fee,
]
class ItemizedPurchaseReport(Report):
"""
Subclass of Report, used to generate itemized purchase reports.
"""
def get_report_data(self, start_date, end_date, start_letter=None, end_letter=None):
return OrderItem.objects.filter(
status="purchased",
fulfilled_time__gte=start_date,
fulfilled_time__lt=end_date,
).order_by("fulfilled_time")
def csv_report_header_row(self):
return [
"Purchase Time",
"Order ID",
"Status",
"Quantity",
"Unit Cost",
"Total Cost",
"Currency",
"Description",
"Comments"
]
def csv_report_row(self, item):
return [
item.fulfilled_time,
item.order_id, # pylint: disable=no-member
item.status,
item.qty,
item.unit_cost,
item.line_cost,
item.currency,
item.line_desc,
item.report_comments,
]
class CertificateStatusReport(Report):
"""
Subclass of Report, used to generate Certificate Status Reports for ed services.
"""
def get_report_data(self, start_date, end_date, start_letter=None, end_letter=None):
results = []
for course_id in settings.COURSE_LISTINGS['default']:
if (start_letter.lower() <= course_id.lower()) and (end_letter.lower() >= course_id.lower()) and (get_course_by_id(course_id) is not None):
cur_course = get_course_by_id(course_id)
university = cur_course.org
course = cur_course.number + " " + cur_course.display_name # TODO add term (i.e. Fall 2013)?
enrollments = CourseEnrollment.enrollments_in(course_id)
total_enrolled = enrollments.count()
audit_enrolled = enrollments.filter(mode="audit").count()
honor_enrolled = enrollments.filter(mode="honor").count()
# Since every verified enrollment has 1 and only 1 cert item, let's just query those
verified_enrollments = CertificateItem.objects.filter(course_id=course_id, mode="verified", status="purchased")
verified_enrolled = verified_enrollments.count()
gross_rev_temp = CertificateItem.objects.filter(course_id=course_id, mode="verified", status="purchased").aggregate(Sum('unit_cost'))
gross_rev = gross_rev_temp['unit_cost__sum']
gross_rev_over_min = gross_rev - (CourseMode.objects.get(course_id=course_id, mode_slug="verified").min_price * verified_enrolled)
refunded_enrollments = CertificateItem.objects.filter(course_id='course_id', mode="verified", status="refunded")
number_of_refunds = refunded_enrollments.count()
dollars_refunded_temp = refunded_enrollments.aggregate(Sum('unit_cost'))
if dollars_refunded_temp['unit_cost__sum'] is None:
dollars_refunded = Decimal(0.00)
else:
dollars_refunded = dollars_refunded_temp['unit_cost__sum']
result = [
university,
course,
total_enrolled,
audit_enrolled,
honor_enrolled,
verified_enrolled,
gross_rev,
gross_rev_over_min,
number_of_refunds,
dollars_refunded
]
results.append(result)
return results
def csv_report_header_row(self):
return [
"University",
"Course",
"Total Enrolled",
"Audit Enrollment",
"Honor Code Enrollment",
"Verified Enrollment",
"Gross Revenue",
"Gross Revenue over the Minimum",
"Number of Refunds",
"Dollars Refunded",
]
def csv_report_row(self, item):
return item
class UniversityRevenueShareReport(Report):
"""
Subclass of Report, used to generate University Revenue Share Reports for finance purposes.
"""
def get_report_data(self, start_date, end_date, start_letter=None, end_letter=None):
results = []
for course_id in settings.COURSE_LISTINGS['default']:
if (start_letter.lower() <= course_id.lower()) and (end_letter.lower() >= course_id.lower()):
try:
cur_course = get_course_by_id(course_id)
except:
break
university = cur_course.org
course = cur_course.number + " " + cur_course.display_name
num_transactions = 0 # TODO clarify with billing what transactions are included in this (purchases? refunds? etc)
all_paid_certs = CertificateItem.objects.filter(course_id=course_id, status="purchased")
total_payments_collected_temp = all_paid_certs.aggregate(Sum('unit_cost'))
if total_payments_collected_temp['unit_cost__sum'] is None:
total_payments_collected = Decimal(0.00)
else:
total_payments_collected = total_payments_collected_temp['unit_cost__sum']
total_service_fees_temp = all_paid_certs.aggregate(Sum('service_fee'))
if total_service_fees_temp['service_fee__sum'] is None:
service_fees = Decimal(0.00)
else:
service_fees = total_service_fees_temp['service_fee__sum']
refunded_enrollments = CertificateItem.objects.filter(course_id=course_id, status="refunded")
num_refunds = refunded_enrollments.count()
amount_refunds_temp = refunded_enrollments.aggregate(Sum('unit_cost'))
if amount_refunds_temp['unit_cost__sum'] is None:
amount_refunds = Decimal(0.00)
else:
amount_refunds = amount_refunds_temp['unit_cost__sum']
result = [
university,
course,
num_transactions,
total_payments_collected,
service_fees,
num_refunds,
amount_refunds
]
results.append(result)
return results
def csv_report_header_row(self):
return [
"University",
"Course",
"Number of Transactions",
"Total Payments Collected",
"Service Fees (if any)",
"Number of Successful Refunds",
"Total Amount of Refunds",
]
def csv_report_row(self, item):
return item
......@@ -17,14 +17,31 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from shoppingcart.models import (Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration,
OrderItemSubclassPK, PaidCourseRegistrationAnnotation, Report)
OrderItemSubclassPK, PaidCourseRegistrationAnnotation)
from shoppingcart.reports import ItemizedPurchaseReport, CertificateStatusReport, UniversityRevenueShareReport, RefundReport
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from shoppingcart.exceptions import PurchasedCallbackException
from shoppingcart.exceptions import PurchasedCallbackException, ReportTypeDoesNotExistException
import pytz
import datetime
REPORT_TYPES = [
("refund_report", RefundReport),
("itemized_purchase_report", ItemizedPurchaseReport),
("university_revenue_share", UniversityRevenueShareReport),
("certificate_status", CertificateStatusReport),
]
def initialize_report(report_type):
"""
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]()
raise ReportTypeDoesNotExistException
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class OrderTest(ModuleStoreTestCase):
......@@ -355,13 +372,12 @@ class ItemizedPurchaseReportTest(ModuleStoreTestCase):
self.now = datetime.datetime.now(pytz.UTC)
def test_purchased_items_btw_dates(self):
report_type = "itemized_purchase_report"
report = Report.initialize_report(report_type)
purchases = report.get_query(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
report = initialize_report("itemized_purchase_report")
purchases = report.get_report_data(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
self.assertEqual(len(purchases), 2)
self.assertIn(self.reg.orderitem_ptr, purchases)
self.assertIn(self.cert_item.orderitem_ptr, purchases)
no_purchases = report.get_query(self.now + self.FIVE_MINS, self.now + self.FIVE_MINS + self.FIVE_MINS)
no_purchases = report.get_report_data(self.now + self.FIVE_MINS, self.now + self.FIVE_MINS + self.FIVE_MINS)
self.assertFalse(no_purchases)
test_time = datetime.datetime.now(pytz.UTC)
......@@ -376,14 +392,13 @@ class ItemizedPurchaseReportTest(ModuleStoreTestCase):
"""
Tests that a generated purchase report CSV is as we expect
"""
report_type = "itemized_purchase_report"
report = Report.initialize_report(report_type)
for item in report.get_query(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS):
report = initialize_report("itemized_purchase_report")
for item in report.get_report_data(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS):
item.fulfilled_time = self.test_time
item.save()
csv_file = StringIO.StringIO()
Report.make_report(report_type, csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
report.make_report(csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
csv = csv_file.getvalue()
csv_file.close()
# Using excel mode csv, which automatically ends lines with \r\n, so need to convert to \n
......@@ -406,163 +421,6 @@ class ItemizedPurchaseReportTest(ModuleStoreTestCase):
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class ReportTypeTests(ModuleStoreTestCase):
"""
Tests for the models used to generate certificate status reports
"""
FIVE_MINS = datetime.timedelta(minutes=5)
def setUp(self):
# Need to make a *lot* of users for this one
self.user1 = UserFactory.create()
self.user1.first_name = "John"
self.user1.last_name = "Doe"
self.user1.save()
self.user2 = UserFactory.create()
self.user2.first_name = "Jane"
self.user2.last_name = "Deer"
self.user2.save()
self.user3 = UserFactory.create()
self.user3.first_name = "Joe"
self.user3.last_name = "Miller"
self.user3.save()
self.user4 = UserFactory.create()
self.user4.first_name = "Simon"
self.user4.last_name = "Blackquill"
self.user4.save()
self.user5 = UserFactory.create()
self.user5.first_name = "Super"
self.user5.last_name = "Mario"
self.user5.save()
self.user6 = UserFactory.create()
self.user6.first_name = "Princess"
self.user6.last_name = "Peach"
self.user6.save()
self.user7 = UserFactory.create()
self.user7.first_name = "King"
self.user7.last_name = "Bowser"
self.user7.save()
self.user8 = UserFactory.create()
self.user8.first_name = "Susan"
self.user8.last_name = "Smith"
self.user8.save()
# Two are verified, three are audit, one honor
self.course_id = "MITx/999/Robot_Super_Course"
settings.COURSE_LISTINGS['default'] = [self.course_id]
self.cost = 40
self.course = CourseFactory.create(org='MITx', number='999', display_name=u'Robot Super Course')
course_mode = CourseMode(course_id=self.course_id,
mode_slug="honor",
mode_display_name="honor cert",
min_price=self.cost)
course_mode.save()
course_mode2 = CourseMode(course_id=self.course_id,
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.user1)
CertificateItem.add_to_order(self.cart1, self.course_id, self.cost, 'verified')
self.cart1.purchase()
self.cart2 = Order.get_cart_for_user(self.user2)
CertificateItem.add_to_order(self.cart2, self.course_id, self.cost, 'verified')
self.cart2.purchase()
# Users 3, 4, and 5 are audit
CourseEnrollment.enroll(self.user3, self.course_id, "audit")
CourseEnrollment.enroll(self.user4, self.course_id, "audit")
CourseEnrollment.enroll(self.user5, self.course_id, "audit")
# User 6 is honor
CourseEnrollment.enroll(self.user6, self.course_id, "honor")
self.now = datetime.datetime.now(pytz.UTC)
# Users 7 & 8 are refunds
self.cart = Order.get_cart_for_user(self.user7)
CertificateItem.add_to_order(self.cart, self.course_id, self.cost, 'verified')
self.cart.purchase()
CourseEnrollment.unenroll(self.user7, self.course_id)
self.cart = Order.get_cart_for_user(self.user8)
CertificateItem.add_to_order(self.cart, self.course_id, self.cost, 'verified')
self.cart.purchase(self.user8, self.course_id)
CourseEnrollment.unenroll(self.user8, self.course_id)
self.test_time = datetime.datetime.now(pytz.UTC)
self.CORRECT_REFUND_REPORT_CSV = dedent("""
Order Number,Customer Name,Date of Original Transaction,Date of Refund,Amount of Refund,Service Fees (if any)
3,King Bowser,{time_str},{time_str},40,0
4,Susan Smith,{time_str},{time_str},40,0
""".format(time_str=str(self.test_time)))
self.CORRECT_CERT_STATUS_CSV = dedent("""
University,Course,Total Enrolled,Audit Enrollment,Honor Code Enrollment,Verified Enrollment,Gross Revenue,Gross Revenue over the Minimum,Number of Refunds,Dollars Refunded
MITx,999 Robot Super Course,6,3,1,2,80.00,0.00,0,0
""".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,0,80.00,0.00,2,80.00
""".format(time_str=str(self.test_time)))
def test_refund_report_get_query(self):
report_type = "refund_report"
report = Report.initialize_report(report_type)
refunded_certs = report.get_query(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
self.assertEqual(len(refunded_certs), 2)
self.assertTrue(CertificateItem.objects.get(user=self.user7, course_id=self.course_id))
self.assertTrue(CertificateItem.objects.get(user=self.user8, course_id=self.course_id))
def test_refund_report_purchased_csv(self):
"""
Tests that a generated purchase report CSV is as we expect
"""
report_type = "refund_report"
report = Report.initialize_report(report_type)
for item in report.get_query(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS):
item.fulfilled_time = self.test_time
item.refund_requested_time = self.test_time # hm do we want to make these different
item.save()
csv_file = StringIO.StringIO()
Report.make_report(report_type, csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
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(), self.CORRECT_REFUND_REPORT_CSV.strip())
def test_basic_cert_status_csv(self):
report_type = "certificate_status"
report = Report.initialize_report(report_type)
csv_file = StringIO.StringIO()
report.make_report(report_type, csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
csv = csv_file.getvalue()
self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_CERT_STATUS_CSV.strip())
def test_basic_uni_revenue_share_csv(self):
report_type = "university_revenue_share"
report = Report.initialize_report(report_type)
csv_file = StringIO.StringIO()
report.make_report(report_type, csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
csv = csv_file.getvalue()
self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_UNI_REVENUE_SHARE_CSV.strip())
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class CertificateItemTest(ModuleStoreTestCase):
"""
Tests for verifying specific CertificateItem functionality
......
"""
Tests for the Shopping Cart Models
"""
import StringIO
from textwrap import dedent
from django.conf import settings
from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from shoppingcart.models import (Order, CertificateItem)
from shoppingcart.reports import ItemizedPurchaseReport, CertificateStatusReport, UniversityRevenueShareReport, RefundReport
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from shoppingcart.views import initialize_report
import pytz
import datetime
REPORT_TYPES = [
("refund_report", RefundReport),
("itemized_purchase_report", ItemizedPurchaseReport),
("university_revenue_share", UniversityRevenueShareReport),
("certificate_status", CertificateStatusReport),
]
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class ReportTypeTests(ModuleStoreTestCase):
"""
Tests for the models used to generate certificate status reports
"""
FIVE_MINS = datetime.timedelta(minutes=5)
def setUp(self):
# Need to make a *lot* of users for this one
self.user1 = UserFactory.create()
self.user1.first_name = "John"
self.user1.last_name = "Doe"
self.user1.save()
self.user2 = UserFactory.create()
self.user2.first_name = "Jane"
self.user2.last_name = "Deer"
self.user2.save()
self.user3 = UserFactory.create()
self.user3.first_name = "Joe"
self.user3.last_name = "Miller"
self.user3.save()
self.user4 = UserFactory.create()
self.user4.first_name = "Simon"
self.user4.last_name = "Blackquill"
self.user4.save()
self.user5 = UserFactory.create()
self.user5.first_name = "Super"
self.user5.last_name = "Mario"
self.user5.save()
self.user6 = UserFactory.create()
self.user6.first_name = "Princess"
self.user6.last_name = "Peach"
self.user6.save()
self.user7 = UserFactory.create()
self.user7.first_name = "King"
self.user7.last_name = "Bowser"
self.user7.save()
self.user8 = UserFactory.create()
self.user8.first_name = "Susan"
self.user8.last_name = "Smith"
self.user8.save()
# Two are verified, three are audit, one honor
self.course_id = "MITx/999/Robot_Super_Course"
settings.COURSE_LISTINGS['default'] = [self.course_id]
self.cost = 40
self.course = CourseFactory.create(org='MITx', number='999', display_name=u'Robot Super Course')
course_mode = CourseMode(course_id=self.course_id,
mode_slug="honor",
mode_display_name="honor cert",
min_price=self.cost)
course_mode.save()
course_mode2 = CourseMode(course_id=self.course_id,
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.user1)
CertificateItem.add_to_order(self.cart1, self.course_id, self.cost, 'verified')
self.cart1.purchase()
self.cart2 = Order.get_cart_for_user(self.user2)
CertificateItem.add_to_order(self.cart2, self.course_id, self.cost, 'verified')
self.cart2.purchase()
# Users 3, 4, and 5 are audit
CourseEnrollment.enroll(self.user3, self.course_id, "audit")
CourseEnrollment.enroll(self.user4, self.course_id, "audit")
CourseEnrollment.enroll(self.user5, self.course_id, "audit")
# User 6 is honor
CourseEnrollment.enroll(self.user6, self.course_id, "honor")
self.now = datetime.datetime.now(pytz.UTC)
# Users 7 & 8 are refunds
self.cart = Order.get_cart_for_user(self.user7)
CertificateItem.add_to_order(self.cart, self.course_id, self.cost, 'verified')
self.cart.purchase()
CourseEnrollment.unenroll(self.user7, self.course_id)
self.cart = Order.get_cart_for_user(self.user8)
CertificateItem.add_to_order(self.cart, self.course_id, self.cost, 'verified')
self.cart.purchase(self.user8, self.course_id)
CourseEnrollment.unenroll(self.user8, self.course_id)
self.test_time = datetime.datetime.now(pytz.UTC)
self.CORRECT_REFUND_REPORT_CSV = dedent("""
Order Number,Customer Name,Date of Original Transaction,Date of Refund,Amount of Refund,Service Fees (if any)
3,King Bowser,{time_str},{time_str},40,0
4,Susan Smith,{time_str},{time_str},40,0
""".format(time_str=str(self.test_time)))
self.CORRECT_CERT_STATUS_CSV = dedent("""
University,Course,Total Enrolled,Audit Enrollment,Honor Code Enrollment,Verified Enrollment,Gross Revenue,Gross Revenue over the Minimum,Number of Refunds,Dollars Refunded
MITx,999 Robot Super Course,6,3,1,2,80.00,0.00,0,0
""".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,0,80.00,0.00,2,80.00
""".format(time_str=str(self.test_time)))
def test_refund_report_get_report_data(self):
report = initialize_report("refund_report")
refunded_certs = report.get_report_data(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
self.assertEqual(len(refunded_certs), 2)
self.assertTrue(CertificateItem.objects.get(user=self.user7, course_id=self.course_id))
self.assertTrue(CertificateItem.objects.get(user=self.user8, course_id=self.course_id))
def test_refund_report_purchased_csv(self):
"""
Tests that a generated purchase report CSV is as we expect
"""
report = initialize_report("refund_report")
for item in report.get_report_data(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS):
item.fulfilled_time = self.test_time
item.refund_requested_time = self.test_time # hm do we want to make these different
item.save()
csv_file = StringIO.StringIO()
report.make_report(csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS)
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(), self.CORRECT_REFUND_REPORT_CSV.strip())
def test_basic_cert_status_csv(self):
report = initialize_report("certificate_status")
csv_file = StringIO.StringIO()
report.make_report(csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS, 'A', 'Z')
csv = csv_file.getvalue()
self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_CERT_STATUS_CSV.strip())
def test_basic_uni_revenue_share_csv(self):
report = initialize_report("university_revenue_share")
csv_file = StringIO.StringIO()
report.make_report(csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS, 'A', 'Z')
csv = csv_file.getvalue()
self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_UNI_REVENUE_SHARE_CSV.strip())
......@@ -14,13 +14,15 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from shoppingcart.views import _can_download_report, _get_date_from_str
from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration, Report
from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from edxmako.shortcuts import render_to_response
from shoppingcart.processors import render_purchase_form_html
from mock import patch, Mock, sentinel
from shoppingcart.reports import ItemizedPurchaseReport
from shoppingcart.views import initialize_report
def mock_render_purchase_form_html(*args, **kwargs):
......@@ -354,7 +356,6 @@ class CSVReportViewsTest(ModuleStoreTestCase):
def test_report_csv_bad_date(self):
self.login_user()
self.add_to_download_group(self.user)
report_type = "itemized_purchase_report"
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
......@@ -386,7 +387,7 @@ class CSVReportViewsTest(ModuleStoreTestCase):
CORRECT_CSV_NO_DATE_ITEMIZED_PURCHASE = ",1,purchased,1,40,40,usd,Registration for Course: Robot Super Course,"
def test_report_csv(self):
def test_report_csv_itemized(self):
report_type = 'itemized_purchase_report'
PaidCourseRegistration.add_to_order(self.cart, self.course_id)
self.cart.purchase()
......@@ -396,10 +397,24 @@ class CSVReportViewsTest(ModuleStoreTestCase):
'end_date': '2100-01-01',
'requested_report': report_type})
self.assertEqual(response['Content-Type'], 'text/csv')
report = Report.initialize_report(report_type)
report = initialize_report(report_type)
self.assertIn(",".join(report.csv_report_header_row()), response.content)
self.assertIn(self.CORRECT_CSV_NO_DATE_ITEMIZED_PURCHASE, response.content)
def test_report_csv_university_revenue_share(self):
report_type = 'university_revenue_share'
self.login_user()
self.add_to_download_group(self.user)
response = self.client.post(reverse('payment_csv_report'), {'start_date': '1970-01-01',
'end_date': '2100-01-01',
'start_letter': 'A',
'end_letter': 'Z',
'requested_report': report_type})
self.assertEqual(response['Content-Type'], 'text/csv')
report = initialize_report(report_type)
self.assertIn(",".join(report.csv_report_header_row()), response.content)
# TODO add another test here
class UtilFnsTest(TestCase):
"""
......
......@@ -12,14 +12,31 @@ from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_response
from student.models import CourseEnrollment
from .models import Order, PaidCourseRegistration, OrderItem, Report
from shoppingcart.reports import RefundReport, ItemizedPurchaseReport, UniversityRevenueShareReport, CertificateStatusReport
from .models import Order, PaidCourseRegistration, OrderItem
from .processors import process_postpay_callback, render_purchase_form_html
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException
log = logging.getLogger("shoppingcart")
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):
"""
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]()
raise ReportTypeDoesNotExistException
@require_POST
def add_course_to_cart(request, course_id):
......@@ -155,7 +172,7 @@ def _get_date_from_str(date_input):
return datetime.datetime.strptime(date_input.strip(), "%Y-%m-%d").replace(tzinfo=pytz.UTC)
def _render_report_form(start_str, end_str, report_type, total_count_error=False, date_fmt_error=False):
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
"""
......@@ -164,6 +181,8 @@ def _render_report_form(start_str, end_str, report_type, total_count_error=False
'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)
......@@ -178,34 +197,44 @@ def csv_report(request):
if not _can_download_report(request.user):
return HttpResponseForbidden(_('You do not have permission to view this page.'))
# TODO temp filler for start letter, end letter
if request.method == 'POST':
start_str = request.POST.get('start_date', '')
end_str = 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_str)
start_date = _get_date_from_str(start_str) + datetime.timedelta(days=0)
end_date = _get_date_from_str(end_str) + datetime.timedelta(days=1)
except ValueError:
# Error case: there was a badly formatted user-input date string
return _render_report_form(start_str, end_str, report_type, date_fmt_error=True)
return _render_report_form(start_str, end_str, start_letter, end_letter, report_type, date_fmt_error=True)
report = initialize_report(report_type)
items = report.get_report_data(start_date, end_date, start_letter, end_letter)
report = Report.initialize_report(report_type)
items = report.get_query(start_date, end_date)
if items.count() > settings.PAYMENT_REPORT_MAX_ITEMS:
# TODO add this back later as a query-est function or something
try:
if items.count() > settings.PAYMENT_REPORT_MAX_ITEMS:
# Error case: too many items would be generated in the report and we're at risk of timeout
return _render_report_form(start_str, end_str, report_type, total_count_error=True)
return _render_report_form(start_str, end_str, start_letter, end_letter, report_type, total_count_error=True)
except:
pass
response = HttpResponse(mimetype='text/csv')
filename = "purchases_report_{}.csv".format(datetime.datetime.now(pytz.UTC).strftime("%Y-%m-%d-%H-%M-%S"))
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
# this flos is a little odd; what's up with report_type being called twice? check later
report.make_report(report_type, response, start_date, end_date)
report.make_report(response, start_date, end_date, start_letter, end_letter)
return response
elif request.method == 'GET':
end_date = datetime.datetime.now(pytz.UTC)
start_date = end_date - datetime.timedelta(days=30)
return _render_report_form(start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d"), report_type="")
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")
......@@ -23,6 +23,10 @@
<input id="start_date" type="text" value="${start_date}" name="start_date"/>
<label for="end_date">${_("End Date: ")}</label>
<input id="end_date" type="text" value="${end_date}" name="end_date"/>
<label for="start_letter">${_("Start Letter: ")}</label>
<input id="start_letter" type="text" value="${start_letter}" name="start_letter"/>
<label for="end_letter">${_("End Letter: ")}</label>
<input id="end_letter" type="text" value="${end_letter}" name="end_letter"/>
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}" />
<br/>
<button type = "submit" name="requested_report" value="itemized_purchase_report">Itemized Purchase Report</button>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment