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)
......
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
"""
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