diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 935abbdd2e..9e98bfd965 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 3063a0b98d..685f320e4d 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -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 diff --git a/lms/djangoapps/shoppingcart/reports.py b/lms/djangoapps/shoppingcart/reports.py new file mode 100644 index 0000000000..b4047b22d5 --- /dev/null +++ b/lms/djangoapps/shoppingcart/reports.py @@ -0,0 +1,249 @@ +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 diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 80dd5a1423..b368837b14 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -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 @@ -405,163 +420,6 @@ class ItemizedPurchaseReportTest(ModuleStoreTestCase): self.assertEqual(unicode(self.annotation), u'{} : {}'.format(self.course_id, self.TEST_ANNOTATION)) -@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): """ diff --git a/lms/djangoapps/shoppingcart/tests/test_reports.py b/lms/djangoapps/shoppingcart/tests/test_reports.py new file mode 100644 index 0000000000..e5312ec594 --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/test_reports.py @@ -0,0 +1,179 @@ +""" +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()) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index a08decd05f..949a570a0b 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -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): """ diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index b19e2244f7..617cd2c58a 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -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 = Report.initialize_report(report_type) - items = report.get_query(start_date, end_date) - if items.count() > settings.PAYMENT_REPORT_MAX_ITEMS: + report = initialize_report(report_type) + items = report.get_report_data(start_date, end_date, start_letter, end_letter) + + # 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") diff --git a/lms/templates/shoppingcart/download_report.html b/lms/templates/shoppingcart/download_report.html index 67cdbce913..9dc5548895 100644 --- a/lms/templates/shoppingcart/download_report.html +++ b/lms/templates/shoppingcart/download_report.html @@ -23,6 +23,10 @@ + + + +