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 @@
+
+
+
+