From 10f8d8c0975a0bacb8a7d290b0b9dca5a25885ce Mon Sep 17 00:00:00 2001 From: Muhammad Shoaib Date: Wed, 19 Nov 2014 14:39:32 +0500 Subject: [PATCH] WL-135 added the migration for the expiration_date field in the coupons model added the functionality of adding the expiration date, and also handled in the shopping cart when the user try to add the expired coupon code in the shopping cart added expiration date in the coupons list in the ecommerce page added the unit tests and jasmine tests fix the quality issues and rebased with master regenerated the migration file with a different number i18n and verified te course mode changes suggested by stephan --- lms/djangoapps/instructor/tests/test_api.py | 23 +- .../instructor/tests/test_ecommerce.py | 21 +- lms/djangoapps/instructor/views/api.py | 12 +- lms/djangoapps/instructor/views/coupons.py | 22 +- lms/djangoapps/instructor_analytics/basic.py | 3 +- .../instructor_analytics/tests/test_basic.py | 24 +- ..._auto__add_field_coupon_expiration_date.py | 219 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 9 + .../shoppingcart/tests/test_views.py | 2 +- lms/djangoapps/shoppingcart/views.py | 13 +- .../js/instructor_dashboard/ecommerce.js | 35 +++ .../instructor_dashboard/ecommerce_spec.js | 36 +++ lms/static/js/spec/main.js | 5 + .../sass/course/instructor/_instructor_2.scss | 36 ++- .../add_coupon_modal.html | 6 + .../instructor_dashboard_2/e-commerce.html | 30 ++- .../edit_coupon_modal.html | 6 + .../instructor_dashboard_2.html | 1 + 18 files changed, 476 insertions(+), 27 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0023_auto__add_field_coupon_expiration_date.py create mode 100644 lms/static/js/instructor_dashboard/ecommerce.js create mode 100644 lms/static/js/spec/instructor_dashboard/ecommerce_spec.js 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 +