CSV Reporting of Shopping Cart Purchases, with tests
squashing to one commit to make cherry-picking by feature possible
This commit is contained in:
7
lms/djangoapps/shoppingcart/admin.py
Normal file
7
lms/djangoapps/shoppingcart/admin.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Allows django admin site to add PaidCourseRegistrationAnnotations
|
||||
"""
|
||||
from ratelimitbackend import admin
|
||||
from shoppingcart.models import PaidCourseRegistrationAnnotation
|
||||
|
||||
admin.site.register(PaidCourseRegistrationAnnotation)
|
||||
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'PaidCourseRegistrationAnnotation'
|
||||
db.create_table('shoppingcart_paidcourseregistrationannotation', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=128, db_index=True)),
|
||||
('annotation', self.gf('django.db.models.fields.TextField')(null=True)),
|
||||
))
|
||||
db.send_create_signal('shoppingcart', ['PaidCourseRegistrationAnnotation'])
|
||||
|
||||
# Adding field 'OrderItem.report_comments'
|
||||
db.add_column('shoppingcart_orderitem', 'report_comments',
|
||||
self.gf('django.db.models.fields.TextField')(default=''),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'PaidCourseRegistrationAnnotation'
|
||||
db.delete_table('shoppingcart_paidcourseregistrationannotation')
|
||||
|
||||
# Deleting field 'OrderItem.report_comments'
|
||||
db.delete_column('shoppingcart_orderitem', 'report_comments')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'shoppingcart.certificateitem': {
|
||||
'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']},
|
||||
'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}),
|
||||
'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'})
|
||||
},
|
||||
'shoppingcart.order': {
|
||||
'Meta': {'object_name': 'Order'},
|
||||
'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
|
||||
'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}),
|
||||
'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}),
|
||||
'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}),
|
||||
'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'shoppingcart.orderitem': {
|
||||
'Meta': {'object_name': 'OrderItem'},
|
||||
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
|
||||
'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}),
|
||||
'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}),
|
||||
'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
|
||||
'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}),
|
||||
'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'shoppingcart.paidcourseregistration': {
|
||||
'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}),
|
||||
'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'})
|
||||
},
|
||||
'shoppingcart.paidcourseregistrationannotation': {
|
||||
'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'},
|
||||
'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['shoppingcart']
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
||||
import pytz
|
||||
import logging
|
||||
import smtplib
|
||||
import unicodecsv
|
||||
|
||||
from model_utils.managers import InheritanceManager
|
||||
from collections import namedtuple
|
||||
@@ -207,6 +208,8 @@ class OrderItem(models.Model):
|
||||
line_desc = models.CharField(default="Misc. Item", max_length=1024)
|
||||
currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes
|
||||
fulfilled_time = models.DateTimeField(null=True)
|
||||
# general purpose field, not user-visible. Used for reporting
|
||||
report_comments = models.TextField(default="")
|
||||
|
||||
@property
|
||||
def line_cost(self):
|
||||
@@ -254,6 +257,66 @@ class OrderItem(models.Model):
|
||||
"""
|
||||
return self.pk_with_subclass, set([])
|
||||
|
||||
@classmethod
|
||||
def purchased_items_btw_dates(cls, start_date, end_date):
|
||||
"""
|
||||
Returns a QuerySet of the purchased items between start_date and end_date inclusive.
|
||||
"""
|
||||
return cls.objects.filter(
|
||||
status="purchased",
|
||||
fulfilled_time__gte=start_date,
|
||||
fulfilled_time__lt=end_date,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def csv_purchase_report_btw_dates(cls, filelike, start_date, end_date):
|
||||
"""
|
||||
Outputs a CSV report into "filelike" (a file-like python object, such as an actual file, an HttpRequest,
|
||||
or sys.stdout) of purchased items between start_date and end_date inclusive.
|
||||
Opening and closing filelike (if applicable) should be taken care of by the caller
|
||||
"""
|
||||
items = cls.purchased_items_btw_dates(start_date, end_date).order_by("fulfilled_time")
|
||||
|
||||
writer = unicodecsv.writer(filelike, encoding="utf-8")
|
||||
writer.writerow(OrderItem.csv_report_header_row())
|
||||
|
||||
for item in items:
|
||||
writer.writerow(item.csv_report_row)
|
||||
|
||||
@classmethod
|
||||
def csv_report_header_row(cls):
|
||||
"""
|
||||
Returns the "header" row for a csv report of purchases
|
||||
"""
|
||||
return [
|
||||
"Purchase Time",
|
||||
"Order ID",
|
||||
"Status",
|
||||
"Quantity",
|
||||
"Unit Cost",
|
||||
"Total Cost",
|
||||
"Currency",
|
||||
"Description",
|
||||
"Comments"
|
||||
]
|
||||
|
||||
@property
|
||||
def csv_report_row(self):
|
||||
"""
|
||||
Returns an array which can be fed into csv.writer to write out one csv row
|
||||
"""
|
||||
return [
|
||||
self.fulfilled_time,
|
||||
self.order_id, # pylint: disable=no-member
|
||||
self.status,
|
||||
self.qty,
|
||||
self.unit_cost,
|
||||
self.line_cost,
|
||||
self.currency,
|
||||
self.line_desc,
|
||||
self.report_comments,
|
||||
]
|
||||
|
||||
@property
|
||||
def pk_with_subclass(self):
|
||||
"""
|
||||
@@ -345,13 +408,13 @@ class PaidCourseRegistration(OrderItem):
|
||||
|
||||
item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id)
|
||||
item.status = order.status
|
||||
|
||||
item.mode = course_mode.slug
|
||||
item.qty = 1
|
||||
item.unit_cost = cost
|
||||
item.line_desc = 'Registration for Course: {0}'.format(course.display_name_with_default)
|
||||
item.currency = currency
|
||||
order.currency = currency
|
||||
item.report_comments = item.csv_report_comments
|
||||
order.save()
|
||||
item.save()
|
||||
log.info("User {} added course registration {} to cart: order {}"
|
||||
@@ -391,6 +454,31 @@ class PaidCourseRegistration(OrderItem):
|
||||
|
||||
return self.pk_with_subclass, set([notification])
|
||||
|
||||
@property
|
||||
def csv_report_comments(self):
|
||||
"""
|
||||
Tries to fetch an annotation associated with the course_id from the database. If not found, returns u"".
|
||||
Otherwise returns the annotation
|
||||
"""
|
||||
try:
|
||||
return PaidCourseRegistrationAnnotation.objects.get(course_id=self.course_id).annotation
|
||||
except PaidCourseRegistrationAnnotation.DoesNotExist:
|
||||
return u""
|
||||
|
||||
|
||||
class PaidCourseRegistrationAnnotation(models.Model):
|
||||
"""
|
||||
A model that maps course_id to an additional annotation. This is specifically needed because when Stanford
|
||||
generates report for the paid courses, each report item must contain the payment account associated with a course.
|
||||
And unfortunately we didn't have the concept of a "SKU" or stock item where we could keep this association,
|
||||
so this is to retrofit it.
|
||||
"""
|
||||
course_id = models.CharField(unique=True, max_length=128, db_index=True)
|
||||
annotation = models.TextField(null=True)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} : {}".format(self.course_id, self.annotation)
|
||||
|
||||
|
||||
class CertificateItem(OrderItem):
|
||||
"""
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
Tests for the Shopping Cart Models
|
||||
"""
|
||||
import smtplib
|
||||
import StringIO
|
||||
from textwrap import dedent
|
||||
from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors
|
||||
|
||||
from mock import patch, MagicMock
|
||||
@@ -15,7 +17,7 @@ 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)
|
||||
OrderItemSubclassPK, PaidCourseRegistrationAnnotation)
|
||||
from student.tests.factories import UserFactory
|
||||
from student.models import CourseEnrollment
|
||||
from course_modes.models import CourseMode
|
||||
@@ -321,6 +323,87 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id))
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class PurchaseReportTest(ModuleStoreTestCase):
|
||||
|
||||
FIVE_MINS = datetime.timedelta(minutes=5)
|
||||
TEST_ANNOTATION = u'Ba\xfc\u5305'
|
||||
|
||||
def setUp(self):
|
||||
self.user = UserFactory.create()
|
||||
self.course_id = "MITx/999/Robot_Super_Course"
|
||||
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()
|
||||
self.annotation = PaidCourseRegistrationAnnotation(course_id=self.course_id, annotation=self.TEST_ANNOTATION)
|
||||
self.annotation.save()
|
||||
self.cart = Order.get_cart_for_user(self.user)
|
||||
self.reg = PaidCourseRegistration.add_to_order(self.cart, self.course_id)
|
||||
self.cert_item = CertificateItem.add_to_order(self.cart, self.course_id, self.cost, 'verified')
|
||||
self.cart.purchase()
|
||||
self.now = datetime.datetime.now(pytz.UTC)
|
||||
|
||||
def test_purchased_items_btw_dates(self):
|
||||
purchases = OrderItem.purchased_items_btw_dates(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 = OrderItem.purchased_items_btw_dates(self.now + self.FIVE_MINS,
|
||||
self.now + self.FIVE_MINS + self.FIVE_MINS)
|
||||
self.assertFalse(no_purchases)
|
||||
|
||||
test_time = datetime.datetime.now(pytz.UTC)
|
||||
|
||||
CORRECT_CSV = dedent("""
|
||||
Purchase Time,Order ID,Status,Quantity,Unit Cost,Total Cost,Currency,Description,Comments
|
||||
{time_str},1,purchased,1,40,40,usd,Registration for Course: Robot Super Course,Ba\xc3\xbc\xe5\x8c\x85
|
||||
{time_str},1,purchased,1,40,40,usd,"Certificate of Achievement, verified cert for course Robot Super Course",
|
||||
""".format(time_str=str(test_time)))
|
||||
|
||||
def test_purchased_csv(self):
|
||||
"""
|
||||
Tests that a generated purchase report CSV is as we expect
|
||||
"""
|
||||
# coerce the purchase times to self.test_time so that the test can match.
|
||||
# It's pretty hard to patch datetime.datetime b/c it's a python built-in, which is immutable, so we
|
||||
# make the times match this way
|
||||
for item in OrderItem.purchased_items_btw_dates(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS):
|
||||
item.fulfilled_time = self.test_time
|
||||
item.save()
|
||||
|
||||
# add annotation to the
|
||||
csv_file = StringIO.StringIO()
|
||||
OrderItem.csv_purchase_report_btw_dates(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_CSV.strip())
|
||||
|
||||
def test_csv_report_no_annotation(self):
|
||||
"""
|
||||
Fill in gap in test coverage. csv_report_comments for PaidCourseRegistration instance with no
|
||||
matching annotation
|
||||
"""
|
||||
# delete the matching annotation
|
||||
self.annotation.delete()
|
||||
self.assertEqual(u"", self.reg.csv_report_comments)
|
||||
|
||||
def test_paidcourseregistrationannotation_unicode(self):
|
||||
"""
|
||||
Fill in gap in test coverage. __unicode__ method of PaidCourseRegistrationAnnotation
|
||||
"""
|
||||
self.assertEqual(unicode(self.annotation), u'{} : {}'.format(self.course_id, self.TEST_ANNOTATION))
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class CertificateItemTest(ModuleStoreTestCase):
|
||||
"""
|
||||
|
||||
@@ -3,23 +3,23 @@ Tests for Shopping Cart views
|
||||
"""
|
||||
from urlparse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from shoppingcart.views import add_course_to_cart
|
||||
from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration
|
||||
from shoppingcart.views import _can_download_report, _get_date_from_str
|
||||
from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration, OrderItem
|
||||
from student.tests.factories import UserFactory
|
||||
from student.models import CourseEnrollment
|
||||
from course_modes.models import CourseMode
|
||||
from ..exceptions import PurchasedCallbackException
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from shoppingcart.processors import render_purchase_form_html, process_postpay_callback
|
||||
from shoppingcart.processors import render_purchase_form_html
|
||||
from mock import patch, Mock
|
||||
|
||||
|
||||
@@ -232,3 +232,143 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
((template, _context), _tmp) = render_mock.call_args
|
||||
self.assertEqual(template, cert_item.single_item_receipt_template)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class CSVReportViewsTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Test suite for CSV Purchase Reporting
|
||||
"""
|
||||
def setUp(self):
|
||||
self.user = UserFactory.create()
|
||||
self.user.set_password('password')
|
||||
self.user.save()
|
||||
self.course_id = "MITx/999/Robot_Super_Course"
|
||||
self.cost = 40
|
||||
self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course')
|
||||
self.course_mode = CourseMode(course_id=self.course_id,
|
||||
mode_slug="honor",
|
||||
mode_display_name="honor cert",
|
||||
min_price=self.cost)
|
||||
self.course_mode.save()
|
||||
self.verified_course_id = 'org/test/Test_Course'
|
||||
CourseFactory.create(org='org', number='test', run='course1', display_name='Test Course')
|
||||
self.cart = Order.get_cart_for_user(self.user)
|
||||
self.dl_grp = Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP)
|
||||
self.dl_grp.save()
|
||||
|
||||
def login_user(self):
|
||||
"""
|
||||
Helper fn to login self.user
|
||||
"""
|
||||
self.client.login(username=self.user.username, password="password")
|
||||
|
||||
def add_to_download_group(self, user):
|
||||
"""
|
||||
Helper fn to add self.user to group that's allowed to download report CSV
|
||||
"""
|
||||
user.groups.add(self.dl_grp)
|
||||
|
||||
def test_report_csv_no_access(self):
|
||||
self.login_user()
|
||||
response = self.client.get(reverse('payment_csv_report'))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_report_csv_bad_method(self):
|
||||
self.login_user()
|
||||
self.add_to_download_group(self.user)
|
||||
response = self.client.put(reverse('payment_csv_report'))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch('shoppingcart.views.render_to_response', render_mock)
|
||||
def test_report_csv_get(self):
|
||||
self.login_user()
|
||||
self.add_to_download_group(self.user)
|
||||
response = self.client.get(reverse('payment_csv_report'))
|
||||
|
||||
((template, context), unused_kwargs) = render_mock.call_args
|
||||
self.assertEqual(template, 'shoppingcart/download_report.html')
|
||||
self.assertFalse(context['total_count_error'])
|
||||
self.assertFalse(context['date_fmt_error'])
|
||||
self.assertIn(_("Download Purchase Report"), response.content)
|
||||
|
||||
@patch('shoppingcart.views.render_to_response', render_mock)
|
||||
def test_report_csv_bad_date(self):
|
||||
self.login_user()
|
||||
self.add_to_download_group(self.user)
|
||||
response = self.client.post(reverse('payment_csv_report'), {'start_date': 'BAD', 'end_date': 'BAD'})
|
||||
|
||||
((template, context), unused_kwargs) = render_mock.call_args
|
||||
self.assertEqual(template, 'shoppingcart/download_report.html')
|
||||
self.assertFalse(context['total_count_error'])
|
||||
self.assertTrue(context['date_fmt_error'])
|
||||
self.assertIn(_("There was an error in your date input. It should be formatted as YYYY-MM-DD"),
|
||||
response.content)
|
||||
|
||||
@patch('shoppingcart.views.render_to_response', render_mock)
|
||||
@override_settings(PAYMENT_REPORT_MAX_ITEMS=0)
|
||||
def test_report_csv_too_long(self):
|
||||
PaidCourseRegistration.add_to_order(self.cart, self.course_id)
|
||||
self.cart.purchase()
|
||||
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'})
|
||||
|
||||
((template, context), unused_kwargs) = render_mock.call_args
|
||||
self.assertEqual(template, 'shoppingcart/download_report.html')
|
||||
self.assertTrue(context['total_count_error'])
|
||||
self.assertFalse(context['date_fmt_error'])
|
||||
self.assertIn(_("There are too many results in your report.") + " (>0)", response.content)
|
||||
|
||||
# just going to ignored the date in this test, since we already deal with date testing
|
||||
# in test_models.py
|
||||
CORRECT_CSV_NO_DATE = ",1,purchased,1,40,40,usd,Registration for Course: Robot Super Course,"
|
||||
|
||||
def test_report_csv(self):
|
||||
PaidCourseRegistration.add_to_order(self.cart, self.course_id)
|
||||
self.cart.purchase()
|
||||
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'})
|
||||
self.assertEqual(response['Content-Type'], 'text/csv')
|
||||
self.assertIn(",".join(OrderItem.csv_report_header_row()), response.content)
|
||||
self.assertIn(self.CORRECT_CSV_NO_DATE, response.content)
|
||||
|
||||
|
||||
class UtilFnsTest(TestCase):
|
||||
"""
|
||||
Tests for utility functions in views.py
|
||||
"""
|
||||
def setUp(self):
|
||||
self.user = UserFactory.create()
|
||||
|
||||
def test_can_download_report_no_group(self):
|
||||
"""
|
||||
Group controlling perms is not present
|
||||
"""
|
||||
self.assertFalse(_can_download_report(self.user))
|
||||
|
||||
def test_can_download_report_not_member(self):
|
||||
"""
|
||||
User is not part of group controlling perms
|
||||
"""
|
||||
Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP).save()
|
||||
self.assertFalse(_can_download_report(self.user))
|
||||
|
||||
def test_can_download_report(self):
|
||||
"""
|
||||
User is part of group controlling perms
|
||||
"""
|
||||
grp = Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP)
|
||||
grp.save()
|
||||
self.user.groups.add(grp)
|
||||
self.assertTrue(_can_download_report(self.user))
|
||||
|
||||
def test_get_date_from_str(self):
|
||||
test_str = "2013-10-01"
|
||||
date = _get_date_from_str(test_str)
|
||||
self.assertEqual(2013, date.year)
|
||||
self.assertEqual(10, date.month)
|
||||
self.assertEqual(1, date.day)
|
||||
|
||||
@@ -12,6 +12,7 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']:
|
||||
url(r'^clear/$', 'clear_cart'),
|
||||
url(r'^remove_item/$', 'remove_item'),
|
||||
url(r'^add/course/(?P<course_id>[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'),
|
||||
url(r'^csv_report/$', 'csv_report', name='payment_csv_report'),
|
||||
)
|
||||
|
||||
if settings.MITX_FEATURES.get('ENABLE_PAYMENT_FAKE'):
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import logging
|
||||
import datetime
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseNotFound,
|
||||
HttpResponseBadRequest, HttpResponseForbidden, Http404)
|
||||
from django.utils.translation import ugettext as _
|
||||
@@ -121,3 +125,73 @@ def show_receipt(request, ordernum):
|
||||
context.update(order_items[0].single_item_receipt_context)
|
||||
|
||||
return render_to_response(receipt_template, context)
|
||||
|
||||
|
||||
def _can_download_report(user):
|
||||
"""
|
||||
Tests if the user can download the payments report, based on membership in a group whose name is determined
|
||||
in settings. If the group does not exist, denies all access
|
||||
"""
|
||||
try:
|
||||
access_group = Group.objects.get(name=settings.PAYMENT_REPORT_GENERATOR_GROUP)
|
||||
except Group.DoesNotExist:
|
||||
return False
|
||||
return access_group in user.groups.all()
|
||||
|
||||
|
||||
def _get_date_from_str(date_input):
|
||||
"""
|
||||
Gets date from the date input string. Lets the ValueError raised by invalid strings be processed by the caller
|
||||
"""
|
||||
return datetime.datetime.strptime(date_input.strip(), "%Y-%m-%d").replace(tzinfo=pytz.UTC)
|
||||
|
||||
|
||||
def _render_report_form(start_str, end_str, total_count_error=False, date_fmt_error=False):
|
||||
"""
|
||||
Helper function that renders the purchase form. Reduces repetition
|
||||
"""
|
||||
context = {
|
||||
'total_count_error': total_count_error,
|
||||
'date_fmt_error': date_fmt_error,
|
||||
'start_date': start_str,
|
||||
'end_date': end_str,
|
||||
}
|
||||
return render_to_response('shoppingcart/download_report.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def csv_report(request):
|
||||
"""
|
||||
Downloads csv reporting of orderitems
|
||||
"""
|
||||
if not _can_download_report(request.user):
|
||||
return HttpResponseForbidden(_('You do not have permission to view this page.'))
|
||||
|
||||
if request.method == 'POST':
|
||||
start_str = request.POST.get('start_date', '')
|
||||
end_str = request.POST.get('end_date', '')
|
||||
try:
|
||||
start_date = _get_date_from_str(start_str)
|
||||
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, date_fmt_error=True)
|
||||
|
||||
items = OrderItem.purchased_items_btw_dates(start_date, end_date)
|
||||
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, total_count_error=True)
|
||||
|
||||
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)
|
||||
OrderItem.csv_purchase_report_btw_dates(response, start_date, end_date)
|
||||
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"))
|
||||
|
||||
else:
|
||||
return HttpResponseBadRequest("HTTP Method Not Supported")
|
||||
|
||||
@@ -148,6 +148,10 @@ PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_
|
||||
PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY',
|
||||
PAID_COURSE_REGISTRATION_CURRENCY)
|
||||
|
||||
# Payment Report Settings
|
||||
PAYMENT_REPORT_GENERATOR_GROUP = ENV_TOKENS.get('PAYMENT_REPORT_GENERATOR_GROUP', PAYMENT_REPORT_GENERATOR_GROUP)
|
||||
PAYMENT_REPORT_MAX_ITEMS = ENV_TOKENS.get('PAYMENT_REPORT_MAX_ITEMS', PAYMENT_REPORT_MAX_ITEMS)
|
||||
|
||||
# Bulk Email overrides
|
||||
BULK_EMAIL_DEFAULT_FROM_EMAIL = ENV_TOKENS.get('BULK_EMAIL_DEFAULT_FROM_EMAIL', BULK_EMAIL_DEFAULT_FROM_EMAIL)
|
||||
BULK_EMAIL_EMAILS_PER_TASK = ENV_TOKENS.get('BULK_EMAIL_EMAILS_PER_TASK', BULK_EMAIL_EMAILS_PER_TASK)
|
||||
|
||||
@@ -542,6 +542,12 @@ CC_PROCESSOR = {
|
||||
}
|
||||
# Setting for PAID_COURSE_REGISTRATION, DOES NOT AFFECT VERIFIED STUDENTS
|
||||
PAID_COURSE_REGISTRATION_CURRENCY = ['usd', '$']
|
||||
|
||||
# Members of this group are allowed to generate payment reports
|
||||
PAYMENT_REPORT_GENERATOR_GROUP = 'shoppingcart_report_access'
|
||||
# Maximum number of rows the report can contain
|
||||
PAYMENT_REPORT_MAX_ITEMS = 10000
|
||||
|
||||
################################# open ended grading config #####################
|
||||
|
||||
#By setting up the default settings with an incorrect user name and password,
|
||||
@@ -899,6 +905,8 @@ BULK_EMAIL_LOG_SENT_EMAILS = False
|
||||
# parallel, and what the SES rate is.
|
||||
BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02
|
||||
|
||||
|
||||
|
||||
################################### APPS ######################################
|
||||
INSTALLED_APPS = (
|
||||
# Standard ones that are always installed...
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
padding: 30px 30px 0 30px;
|
||||
}
|
||||
|
||||
.error_msg {
|
||||
margin: 20px;
|
||||
padding: 5px;
|
||||
color: $red;
|
||||
border: 1px solid $red;
|
||||
|
||||
}
|
||||
|
||||
.cart-list {
|
||||
padding: 30px;
|
||||
margin-top: 40px;
|
||||
|
||||
29
lms/templates/shoppingcart/download_report.html
Normal file
29
lms/templates/shoppingcart/download_report.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<%block name="title"><title>${_("Download Purchase Report")}</title></%block>
|
||||
|
||||
|
||||
<section class="container">
|
||||
<h2>${_("Download CSV of purchase data")}</h2>
|
||||
% if date_fmt_error:
|
||||
<section class="error_msg">
|
||||
${_("There was an error in your date input. It should be formatted as YYYY-MM-DD")}
|
||||
</section>
|
||||
% endif
|
||||
% if total_count_error:
|
||||
<section class="error_msg">
|
||||
${_("There are too many results in your report.")} (>${settings.PAYMENT_REPORT_MAX_ITEMS}).
|
||||
${_("Try making the date range smaller.")}
|
||||
</section>
|
||||
% endif
|
||||
<form method="post">
|
||||
<label for="start_date">${_("Start Date: ")}</label>
|
||||
<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"/>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}" />
|
||||
<input type="submit" />
|
||||
</form>
|
||||
</section>
|
||||
@@ -70,6 +70,7 @@ South==0.7.6
|
||||
sympy==0.7.1
|
||||
xmltodict==0.4.1
|
||||
django-ratelimit-backend==0.6
|
||||
unicodecsv==0.9.4
|
||||
|
||||
# Used for debugging
|
||||
ipython==0.13.1
|
||||
|
||||
Reference in New Issue
Block a user