Commit f94565d6 by Jason Bau

Merge pull request #1685 from edx/jbau/shopping-cart/report

CSV Reporting of Shopping Cart Purchases, with tests
parents 3ed96f75 c8a98051
"""
Allows django admin site to add PaidCourseRegistrationAnnotations
"""
from ratelimitbackend import admin
from shoppingcart.models import PaidCourseRegistrationAnnotation
admin.site.register(PaidCourseRegistrationAnnotation)
# -*- 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']
\ No newline at end of file
......@@ -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
......@@ -322,6 +324,87 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
@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):
"""
Tests for verifying specific CertificateItem functionality
......
......@@ -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'):
......
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")
......@@ -166,6 +166,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)
......
......@@ -550,6 +550,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,
......@@ -909,6 +915,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;
......
<%! 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
......
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