From c8a98051dd99b68da27d4e276b964b1c85beee04 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 15 Nov 2013 21:28:03 -0800 Subject: [PATCH] CSV Reporting of Shopping Cart Purchases, with tests squashing to one commit to make cherry-picking by feature possible --- lms/djangoapps/shoppingcart/admin.py | 7 + ...nannotation__add_field_orderitem_report.py | 132 +++++++++++++++ lms/djangoapps/shoppingcart/models.py | 90 ++++++++++- .../shoppingcart/tests/test_models.py | 85 +++++++++- .../shoppingcart/tests/test_views.py | 150 +++++++++++++++++- lms/djangoapps/shoppingcart/urls.py | 1 + lms/djangoapps/shoppingcart/views.py | 74 +++++++++ lms/envs/aws.py | 4 + lms/envs/common.py | 8 + lms/static/sass/views/_shoppingcart.scss | 8 + .../shoppingcart/download_report.html | 29 ++++ requirements/edx/base.txt | 1 + 12 files changed, 582 insertions(+), 7 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/admin.py create mode 100644 lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py create mode 100644 lms/templates/shoppingcart/download_report.html diff --git a/lms/djangoapps/shoppingcart/admin.py b/lms/djangoapps/shoppingcart/admin.py new file mode 100644 index 0000000000..199a39d7c0 --- /dev/null +++ b/lms/djangoapps/shoppingcart/admin.py @@ -0,0 +1,7 @@ +""" +Allows django admin site to add PaidCourseRegistrationAnnotations +""" +from ratelimitbackend import admin +from shoppingcart.models import PaidCourseRegistrationAnnotation + +admin.site.register(PaidCourseRegistrationAnnotation) diff --git a/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py b/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py new file mode 100644 index 0000000000..04d37c730a --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py @@ -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'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 03be80861a..f8422dbefc 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -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): """ diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index ecb76ac941..a7196aa2a1 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -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): """ diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index d60cab78d9..0451277ce2 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 9522d15298..3653c91524 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -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[^/]+/[^/]+/[^/]+)/$', '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'): diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 8c6d61d532..ad7ef6b080 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -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") diff --git a/lms/envs/aws.py b/lms/envs/aws.py index d524474d5b..204f317d8e 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index c698ce24be..376fdb2405 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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... diff --git a/lms/static/sass/views/_shoppingcart.scss b/lms/static/sass/views/_shoppingcart.scss index d6861fb456..1b3da66893 100644 --- a/lms/static/sass/views/_shoppingcart.scss +++ b/lms/static/sass/views/_shoppingcart.scss @@ -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; diff --git a/lms/templates/shoppingcart/download_report.html b/lms/templates/shoppingcart/download_report.html new file mode 100644 index 0000000000..838b07f145 --- /dev/null +++ b/lms/templates/shoppingcart/download_report.html @@ -0,0 +1,29 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="title">${_("Download Purchase Report")} + + +
+

${_("Download CSV of purchase data")}

+ % if date_fmt_error: +
+ ${_("There was an error in your date input. It should be formatted as YYYY-MM-DD")} +
+ % endif + % if total_count_error: +
+ ${_("There are too many results in your report.")} (>${settings.PAYMENT_REPORT_MAX_ITEMS}). + ${_("Try making the date range smaller.")} +
+ % endif +
+ + + + + + +
+
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index a0199378e7..f198b3ae10 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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