diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 9b8bcc9c12..e884c693df 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -4,6 +4,8 @@ Unit tests for instructor.api methods. """ import datetime import ddt +import random +import pytz import io import json import os @@ -61,7 +63,7 @@ from .test_tools import msk_from_problem_urlname from ..views.tools import get_extended_due EXPECTED_CSV_HEADER = '"code","course_id","company_name","created_by","redeemed_by","invoice_id","purchaser","customer_reference_number","internal_reference"' -EXPECTED_COUPON_CSV_HEADER = '"course_id","percentage_discount","code_redeemed_count","description"' +EXPECTED_COUPON_CSV_HEADER = '"code","course_id","percentage_discount","code_redeemed_count","description"' # ddt data for test cases involving reports REPORTS_DATA = ( @@ -3331,8 +3333,27 @@ class TestCourseRegistrationCodes(ModuleStoreTestCase): ) coupon.save() + #now create coupons with the expiration dates + for i in range(5): + coupon = Coupon( + code='coupon{0}'.format(i), description='test_description', course_id=self.course.id, + percentage_discount='{0}'.format(i), created_by=self.instructor, is_active=True, + expiration_date=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2) + ) + coupon.save() + response = self.client.get(get_coupon_code_url) self.assertEqual(response.status_code, 200, response.content) + # filter all the coupons + for coupon in Coupon.objects.all(): + self.assertIn('"{code}","{course_id}","{discount}","0","{description}","{expiration_date}"'.format( + code=coupon.code, + course_id=coupon.course_id, + discount=coupon.percentage_discount, + description=coupon.description, + expiration_date=coupon.display_expiry_date + ), response.content) + self.assertEqual(response['Content-Type'], 'text/csv') body = response.content.replace('\r', '') self.assertTrue(body.startswith(EXPECTED_COUPON_CSV_HEADER)) diff --git a/lms/djangoapps/instructor/tests/test_ecommerce.py b/lms/djangoapps/instructor/tests/test_ecommerce.py index 52789d17aa..98271f510e 100644 --- a/lms/djangoapps/instructor/tests/test_ecommerce.py +++ b/lms/djangoapps/instructor/tests/test_ecommerce.py @@ -3,6 +3,8 @@ Unit tests for Ecommerce feature flag in new instructor dashboard. """ from django.core.urlresolvers import reverse +import datetime +import pytz from django.test.utils import override_settings from mock import patch @@ -144,13 +146,26 @@ class TestECommerceDashboardViews(ModuleStoreTestCase): """ # URL for add_coupon add_coupon_url = reverse('add_coupon', kwargs={'course_id': self.course.id.to_deprecated_string()}) + expiration_date = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2) + data = { 'code': 'A2314', 'course_id': self.course.id.to_deprecated_string(), - 'description': 'ADSADASDSAD', 'created_by': self.instructor, 'discount': 5 + 'description': 'ADSADASDSAD', 'created_by': self.instructor, 'discount': 5, + 'expiration_date': '{month}/{day}/{year}'.format(month=expiration_date.month, day=expiration_date.day, year=expiration_date.year) } response = self.client.post(add_coupon_url, data) self.assertTrue("coupon with the coupon code ({code}) added successfully".format(code=data['code']) in response.content) + #now add the coupon with the wrong value in the expiration_date + # server will through the ValueError Exception in the expiration_date field + data = { + 'code': '213454', 'course_id': self.course.id.to_deprecated_string(), + 'description': 'ADSADASDSAD', 'created_by': self.instructor, 'discount': 5, + 'expiration_date': expiration_date.strftime('"%d/%m/%Y') + } + response = self.client.post(add_coupon_url, data) + self.assertTrue("Please enter the date in this format i-e month/day/year" in response.content) + data = { 'code': 'A2314', 'course_id': self.course.id.to_deprecated_string(), 'description': 'asdsasda', 'created_by': self.instructor, 'discount': 99 @@ -221,13 +236,15 @@ class TestECommerceDashboardViews(ModuleStoreTestCase): """ coupon = Coupon( code='AS452', description='asdsadsa', course_id=self.course.id.to_deprecated_string(), - percentage_discount=10, created_by=self.instructor + percentage_discount=10, created_by=self.instructor, + expiration_date=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2) ) coupon.save() # URL for edit_coupon_info edit_url = reverse('get_coupon_info', kwargs={'course_id': self.course.id.to_deprecated_string()}) response = self.client.post(edit_url, {'id': coupon.id}) self.assertTrue('coupon with the coupon id ({coupon_id}) updated successfully'.format(coupon_id=coupon.id) in response.content) + self.assertIn(coupon.display_expiry_date, response.content) response = self.client.post(edit_url, {'id': 444444}) self.assertTrue('coupon with the coupon id ({coupon_id}) DoesNotExist'.format(coupon_id=444444) in response.content) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 60dba8d13a..56f6a80979 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -18,6 +18,7 @@ from django.views.decorators.cache import cache_control from django.core.exceptions import ValidationError, PermissionDenied from django.core.mail.message import EmailMessage from django.db import IntegrityError +from django.db.models import Q from django.core.urlresolvers import reverse from django.core.validators import validate_email from django.utils.translation import ugettext as _ @@ -28,6 +29,8 @@ import random import unicodecsv import urllib from util.file import store_uploaded_file, course_and_time_based_filename_generator, FileValidationException, UniversalNewlineIterator +import datetime +import pytz from util.json_request import JsonResponse from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features @@ -1007,9 +1010,14 @@ def get_coupon_codes(request, course_id): # pylint: disable=unused-argument Respond with csv which contains a summary of all Active Coupons. """ course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) - active_coupons = Coupon.objects.filter(course_id=course_id, is_active=True) + active_coupons = Coupon.objects.filter( + Q(course_id=course_id), + Q(is_active=True), + Q(expiration_date__gt=datetime.datetime.now(pytz.UTC)) | + Q(expiration_date__isnull=True) + ) query_features = [ - 'course_id', 'percentage_discount', 'code_redeemed_count', 'description' + 'code', 'course_id', 'percentage_discount', 'code_redeemed_count', 'description', 'expiration_date' ] coupons_list = instructor_analytics.basic.coupon_codes_features(query_features, active_coupons) header, data_rows = instructor_analytics.csvs.format_dictlist(coupons_list, query_features) diff --git a/lms/djangoapps/instructor/views/coupons.py b/lms/djangoapps/instructor/views/coupons.py index d18eb1c1d3..3abfc2c3bb 100644 --- a/lms/djangoapps/instructor/views/coupons.py +++ b/lms/djangoapps/instructor/views/coupons.py @@ -10,7 +10,8 @@ from util.json_request import JsonResponse from django.http import HttpResponse, HttpResponseNotFound from shoppingcart.models import Coupon, CourseRegistrationCode from opaque_keys.edx.locations import SlashSeparatedCourseKey - +import datetime +import pytz import logging log = logging.getLogger(__name__) @@ -79,9 +80,22 @@ def add_coupon(request, course_id): # pylint: disable=unused-argument return JsonResponse({ 'message': _("Please Enter the Coupon Discount Value Less than or Equal to 100") }, status=400) # status code 400: Bad Request + expiration_date = None + if request.POST.get('expiration_date'): + expiration_date = request.POST.get('expiration_date') + try: + expiration_date = datetime.datetime.strptime(expiration_date, "%m/%d/%Y").replace(tzinfo=pytz.UTC) + datetime.timedelta(days=1) + except ValueError: + return JsonResponse({ + 'message': _("Please enter the date in this format i-e month/day/year") + }, status=400) # status code 400: Bad Request + coupon = Coupon( - code=code, description=description, course_id=course_id, - percentage_discount=discount, created_by_id=request.user.id + code=code, description=description, + course_id=course_id, + percentage_discount=discount, + created_by_id=request.user.id, + expiration_date=expiration_date ) coupon.save() return JsonResponse( @@ -143,10 +157,12 @@ def get_coupon_info(request, course_id): # pylint: disable=unused-argument 'message': _("coupon with the coupon id ({coupon_id}) is already inactive").format(coupon_id=coupon_id) }, status=400) # status code 400: Bad Request + expiry_date = coupon.display_expiry_date return JsonResponse({ 'coupon_code': coupon.code, 'coupon_description': coupon.description, 'coupon_course_id': coupon.course_id.to_deprecated_string(), 'coupon_discount': coupon.percentage_discount, + 'expiry_date': expiry_date, 'message': _('coupon with the coupon id ({coupon_id}) updated successfully').format(coupon_id=coupon_id) }) # status code 200: OK by default diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 72237c5fef..7c9726cc90 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -30,7 +30,7 @@ SALE_ORDER_FEATURES = ('id', 'company_name', 'company_contact_name', 'company_co AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at') -COUPON_FEATURES = ('course_id', 'percentage_discount', 'description') +COUPON_FEATURES = ('code', 'course_id', 'percentage_discount', 'description', 'expiration_date') def sale_order_record_features(course_id, features): @@ -228,6 +228,7 @@ def coupon_codes_features(features, coupons_list): # codes csv. In the case of active and generated registration codes the redeemed_by value will be None. # They have not been redeemed yet + coupon_dict['expiration_date'] = coupon.display_expiry_date coupon_dict['course_id'] = coupon_dict['course_id'].to_deprecated_string() return coupon_dict return [extract_coupon(coupon, features) for coupon in coupons_list] diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index fd835578e9..794e4aacc3 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -22,6 +22,10 @@ from courseware.tests.factories import InstructorFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +import datetime +from django.db.models import Q +import pytz + class TestAnalyticsBasic(ModuleStoreTestCase): """ Test basic analytics functions. """ @@ -303,7 +307,7 @@ class TestCourseRegistrationCodeAnalyticsBasic(ModuleStoreTestCase): def test_coupon_codes_features(self): query_features = [ - 'course_id', 'percentage_discount', 'code_redeemed_count', 'description' + 'course_id', 'percentage_discount', 'code_redeemed_count', 'description', 'expiration_date' ] for i in range(10): coupon = Coupon( @@ -314,13 +318,29 @@ class TestCourseRegistrationCodeAnalyticsBasic(ModuleStoreTestCase): is_active=True ) coupon.save() - active_coupons = Coupon.objects.filter(course_id=self.course.id, is_active=True) + #now create coupons with the expiration dates + for i in range(5): + coupon = Coupon( + code='coupon{0}'.format(i), description='test_description', course_id=self.course.id, + percentage_discount='{0}'.format(i), created_by=self.instructor, is_active=True, + expiration_date=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2) + ) + coupon.save() + + active_coupons = Coupon.objects.filter( + Q(course_id=self.course.id), + Q(is_active=True), + Q(expiration_date__gt=datetime.datetime.now(pytz.UTC)) | + Q(expiration_date__isnull=True) + ) active_coupons_list = coupon_codes_features(query_features, active_coupons) self.assertEqual(len(active_coupons_list), len(active_coupons)) for active_coupon in active_coupons_list: self.assertEqual(set(active_coupon.keys()), set(query_features)) self.assertIn(active_coupon['percentage_discount'], [coupon.percentage_discount for coupon in active_coupons]) self.assertIn(active_coupon['description'], [coupon.description for coupon in active_coupons]) + if active_coupon['expiration_date']: + self.assertIn(active_coupon['expiration_date'], [coupon.display_expiry_date for coupon in active_coupons]) self.assertIn( active_coupon['course_id'], [coupon.course_id.to_deprecated_string() for coupon in active_coupons] diff --git a/lms/djangoapps/shoppingcart/migrations/0023_auto__add_field_coupon_expiration_date.py b/lms/djangoapps/shoppingcart/migrations/0023_auto__add_field_coupon_expiration_date.py new file mode 100644 index 0000000000..e109500dc3 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0023_auto__add_field_coupon_expiration_date.py @@ -0,0 +1,219 @@ +# -*- 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 field 'Coupon.expiration_date' + db.add_column('shoppingcart_coupon', 'expiration_date', + self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Coupon.expiration_date' + db.delete_column('shoppingcart_coupon', 'expiration_date') + + + 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': ('xmodule_django.models.CourseKeyField', [], {'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.coupon': { + 'Meta': {'object_name': 'Coupon'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 1, 6, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'expiration_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'percentage_discount': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'shoppingcart.couponredemption': { + 'Meta': {'object_name': 'CouponRedemption'}, + 'coupon': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Coupon']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.courseregcodeitem': { + 'Meta': {'object_name': 'CourseRegCodeItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'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.courseregcodeitemannotation': { + 'Meta': {'object_name': 'CourseRegCodeItemAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.courseregistrationcode': { + 'Meta': {'object_name': 'CourseRegistrationCode'}, + 'code': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 1, 6, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user'", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Invoice']", 'null': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'purchase_order'", 'null': 'True', 'to': "orm['shoppingcart.Order']"}) + }, + 'shoppingcart.donation': { + 'Meta': {'object_name': 'Donation', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'donation_type': ('django.db.models.fields.CharField', [], {'default': "'general'", 'max_length': '32'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.donationconfiguration': { + 'Meta': {'object_name': 'DonationConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.invoice': { + 'Meta': {'object_name': 'Invoice'}, + 'address_line_1': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'address_line_2': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'address_line_3': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'internal_reference': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'is_valid': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'state': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'total_amount': ('django.db.models.fields.FloatField', [], {}), + 'zip': ('django.db.models.fields.CharField', [], {'max_length': '15', 'null': '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'}), + 'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order_type': ('django.db.models.fields.CharField', [], {'default': "'personal'", 'max_length': '32'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'refunded_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'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'list_price': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '30', 'decimal_places': '2'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'service_fee': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32', 'db_index': 'True'}), + '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_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']", 'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'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': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.registrationcoderedemption': { + 'Meta': {'object_name': 'RegistrationCodeRedemption'}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']", 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']", 'null': 'True'}), + 'redeemed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 1, 6, 0, 0)', 'null': 'True'}), + 'redeemed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'registration_code': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.CourseRegistrationCode']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'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 9c4bf906c8..2e4908cca7 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -2,6 +2,7 @@ from collections import namedtuple from datetime import datetime +from datetime import timedelta from decimal import Decimal import analytics import pytz @@ -803,12 +804,20 @@ class Coupon(models.Model): created_by = models.ForeignKey(User) created_at = models.DateTimeField(default=datetime.now(pytz.utc)) is_active = models.BooleanField(default=True) + expiration_date = models.DateTimeField(null=True, blank=True) def __unicode__(self): return "[Coupon] code: {} course: {}".format(self.code, self.course_id) objects = SoftDeleteCouponManager() + @property + def display_expiry_date(self): + """ + return the coupon expiration date in the readable format + """ + return (self.expiration_date - timedelta(days=1)).strftime("%B %d, %Y") if self.expiration_date else None + class CouponRedemption(models.Model): """ diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 0b0750ff36..7e203116f3 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -369,7 +369,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) self.assertEqual(resp.status_code, 404) - self.assertIn("Coupon '{0}' is not valid for any course in the shopping cart.".format(self.coupon_code), resp.content) + self.assertIn("Discount does not exist against code '{0}'.".format(self.coupon_code), resp.content) def test_course_does_not_exist_in_cart_against_valid_reg_code(self): course_key = self.course_key.to_deprecated_string() + 'testing' diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index f2a54457f5..f717b182cd 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -2,6 +2,7 @@ import logging import datetime import decimal import pytz +from django.db.models import Q from django.conf import settings from django.contrib.auth.models import Group from django.http import ( @@ -258,7 +259,12 @@ def use_code(request): Registration Code Redemption page. """ code = request.POST["code"] - coupons = Coupon.objects.filter(code=code, is_active=True) + coupons = Coupon.objects.filter( + Q(code=code), + Q(is_active=True), + Q(expiration_date__gt=datetime.datetime.now(pytz.UTC)) | + Q(expiration_date__isnull=True) + ) if not coupons: # If no coupons then we check that code against course registration code try: @@ -423,9 +429,8 @@ def use_coupon_code(coupons, user): return HttpResponseBadRequest(_("Only one coupon redemption is allowed against an order")) if not is_redemption_applied: - log.warning("Course item does not exist for coupon '{code}'".format(code=coupons[0].code)) - return HttpResponseNotFound( - _("Coupon '{code}' is not valid for any course in the shopping cart.".format(code=coupons[0].code))) + log.warning("Discount does not exist against code '{code}'.".format(code=coupons[0].code)) + return HttpResponseNotFound(_("Discount does not exist against code '{code}'.".format(code=coupons[0].code))) return HttpResponse( json.dumps({'response': 'success', 'coupon_code_applied': True}), diff --git a/lms/static/js/instructor_dashboard/ecommerce.js b/lms/static/js/instructor_dashboard/ecommerce.js new file mode 100644 index 0000000000..7b6f1c30ec --- /dev/null +++ b/lms/static/js/instructor_dashboard/ecommerce.js @@ -0,0 +1,35 @@ +var edx = edx || {}; + +(function(Backbone, $, _) { + 'use strict'; + + edx.instructor_dashboard = edx.instructor_dashboard || {}; + edx.instructor_dashboard.ecommerce = {}; + + edx.instructor_dashboard.ecommerce.ExpiryCouponView = Backbone.View.extend({ + el: 'li#add-coupon-modal-field-expiry', + events: { + 'click input[type="checkbox"]': 'clicked' + }, + initialize: function() { + $('li#add-coupon-modal-field-expiry input[name="expiration_date"]').hide(); + _.bindAll(this, 'clicked'); + }, + clicked: function (event) { + if (event.currentTarget.checked) { + this.$el.find('#coupon_expiration_date').show(); + this.$el.find('#coupon_expiration_date').focus(); + } + else { + this.$el.find('#coupon_expiration_date').hide(); + } + } + }); + + $(function() { + $( "#coupon_expiration_date" ).datepicker({ + minDate: 0 + }); + var view = new edx.instructor_dashboard.ecommerce.ExpiryCouponView(); + }); +}).call(this, Backbone, $, _); \ No newline at end of file diff --git a/lms/static/js/spec/instructor_dashboard/ecommerce_spec.js b/lms/static/js/spec/instructor_dashboard/ecommerce_spec.js new file mode 100644 index 0000000000..38c1163b6f --- /dev/null +++ b/lms/static/js/spec/instructor_dashboard/ecommerce_spec.js @@ -0,0 +1,36 @@ +define(['backbone', 'jquery', 'js/instructor_dashboard/ecommerce', 'js/common_helpers/template_helpers'], + function (Backbone, $, ExpiryCouponView, TemplateHelpers) { + 'use strict'; + var expiryCouponView, createExpiryCoupon; + describe("edx.instructor_dashboard.ecommerce.ExpiryCouponView", function() { + beforeEach(function() { + setFixtures('
  • ') + expiryCouponView = new ExpiryCouponView(); + }); + + it("is defined", function () { + expect(expiryCouponView).toBeDefined(); + }); + + it("triggers the callback when the checkbox is clicked", function () { + var target = expiryCouponView.$el.find('input[type="checkbox"]'); + spyOn(expiryCouponView, 'clicked'); + expiryCouponView.delegateEvents(); + target.click(); + expect(expiryCouponView.clicked).toHaveBeenCalled(); + }); + + it("shows the input field when the checkbox is checked", function () { + var target = expiryCouponView.$el.find('input[type="checkbox"]'); + target.attr("checked","checked"); + target.click(); + expect(expiryCouponView.$el.find('#coupon_expiration_date')).toHaveAttr('style','display: inline;'); + }); + + it("hides the input field when the checkbox is unchecked", function () { + var target = expiryCouponView.$el.find('input[type="checkbox"]'); + expect(expiryCouponView.$el.find('#coupon_expiration_date')).toHaveAttr('style','display: none;'); + + }); + }); + }); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 82594d4af7..49de0cb6c8 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -270,6 +270,10 @@ }, // Backbone classes loaded explicitly until they are converted to use RequireJS + 'js/instructor_dashboard/ecommerce': { + exports: 'edx.instructor_dashboard.ecommerce.ExpiryCouponView', + deps: ['backbone', 'jquery', 'underscore'] + }, 'js/models/cohort': { exports: 'CohortModel', deps: ['backbone'] @@ -497,6 +501,7 @@ 'lms/include/js/spec/views/file_uploader_spec.js', 'lms/include/js/spec/dashboard/donation.js', 'lms/include/js/spec/shoppingcart/shoppingcart_spec.js', + 'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js', 'lms/include/js/spec/student_account/account_spec.js', 'lms/include/js/spec/student_account/access_spec.js', 'lms/include/js/spec/student_account/login_spec.js', diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index a834c6a31e..22f01861a2 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -12,6 +12,9 @@ } } +#ui-datepicker-div{z-index: 12000 !important; width: 16.5em !important} + + .instructor-dashboard-wrapper-2 { position: relative; // display: table; @@ -1325,12 +1328,13 @@ input[name="subject"] { th { text-align: left; border-bottom: 1px solid $border-color-1; + font-size: 16px; &.c_code { - width: 170px; + width: 110px; } &.c_count { - width: 85px; + width: 60px; } &.c_course_id { width: 320px; @@ -1339,11 +1343,14 @@ input[name="subject"] { &.c_discount { width: 90px; } + &.c_expiry { + width: 150px; + } &.c_action { - width: 89px; + width: 60px; } &.c_dsc{ - width: 260px; + width: 200px; word-wrap: break-word; } } @@ -1361,6 +1368,16 @@ input[name="subject"] { } } } + // in_active coupon rows style + .expired_coupon{ + background: #FEEFB3 !important; + + color: rgba(51,51,51,0.2); + border-bottom: 1px solid #fff; + td:nth-child(3) { + text-decoration: line-through; + } + } // coupon items style .coupons-items { @@ -1368,6 +1385,7 @@ input[name="subject"] { padding: ($baseline/2) 0; position: relative; line-height: normal; + font-size: 14px; span.old-price{ left: -75px; position: relative; @@ -1613,7 +1631,15 @@ input[name="subject"] { } } } - + #add-coupon-modal{ + ol.list-input{ + li{ + input[type="checkbox"]#expiry-check , input[type="checkbox"]#expiry-check + label {display: inline-block; width: auto;margin-top: 10px;} + &.full-width{width: 100%;} + input#coupon_expiration_date{width: 278px;display: inline-block;float: right;} + } + } +} } .profile-distribution-widget { diff --git a/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html b/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html index 04cbff4379..590b873c08 100644 --- a/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html +++ b/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html @@ -49,6 +49,12 @@ +
  • + + + +
  • diff --git a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html index a380bf474e..0930e711bb 100644 --- a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html +++ b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html @@ -1,4 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> +<%! from datetime import datetime, timedelta %> +<%! import pytz %> <%page args="section_data"/> <%include file="add_coupon_modal.html" args="section_data=section_data" /> <%include file="edit_coupon_modal.html" args="section_data=section_data" /> @@ -95,6 +97,7 @@ ${_("Code")} ${_("Description")} + ${_("Expiry Date")} ${_("Discount (%)")} ${_("Redeem Count")} ${_("Actions")} @@ -102,14 +105,21 @@ %for coupon in section_data['coupons']: + <% current_date = datetime.now(pytz.UTC) %> + <% coupon_expiry_date = coupon.expiration_date %> %if coupon.is_active == False: - %else: + %elif coupon_expiry_date is not None and current_date >= coupon_expiry_date: + + %else: %endif - ${coupon.code} - ${coupon.description} - ${coupon.percentage_discount} + ${_('{code}').format(code=coupon.code)} + ${_('{description}').format(description=coupon.description)} + + ${coupon.display_expiry_date} + + ${_('{discount}').format(discount=coupon.percentage_discount)} ${ coupon.couponredemption_set.filter(order__status='purchased').count() } [x]${_('Edit')} @@ -184,7 +194,7 @@ } if($('#invoice_number').val() == "") { $('#error-msg').attr('class','error-msgs') - $('#error-msg').html("${_("Invoice number should not be empty.")}").show(); + $('#error-msg').html("${_('Invoice number should not be empty.')}").show(); return } $.ajax({ @@ -224,6 +234,12 @@ $('input#edit_coupon_discount').val(data.coupon_discount); $('textarea#edit_coupon_description').val(data.coupon_description); $('input#edit_coupon_course_id').val(data.coupon_course_id); + if (data.expiry_date) { + $('input#edit_coupon_expiration_date').val(data.expiry_date); + } + else { + $('input#edit_coupon_expiration_date').val("${_('Never Expires')}"); + } $('#edit-modal-trigger').click(); }, error: function(jqXHR, textStatus, errorThrown) { @@ -459,6 +475,7 @@ var coupon_discount = $.trim($('#coupon_discount').val()); var course_id = $.trim($('#coupon_course_id').val()); var description = $.trim($('#coupon_description').val()); + var expiration_date = $.trim($('#coupon_expiration_date').val()); // Check if empty of not if (code === '') { @@ -485,7 +502,8 @@ "code" : code, "discount": coupon_discount, "course_id": course_id, - "description": description + "description": description, + "expiration_date": expiration_date }, url: "${section_data['ajax_add_coupon']}", success: function (data) { diff --git a/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html b/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html index 78a0e94727..0314d26370 100644 --- a/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html +++ b/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html @@ -49,6 +49,12 @@ + +
  • + + +
  • diff --git a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html index efee29766d..a3e389f271 100644 --- a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html @@ -53,6 +53,7 @@ <%static:js group='application'/> ## Backbone classes declared explicitly until RequireJS is supported +