Commit c5746e6d by chrisndodge

Merge pull request #6071 from edx/muhhshoaib/WL-135-add-expiration-dates-to-Coupon-codes

Add expiration dates to Coupon Codes
parents 17c2af23 10f8d8c0
......@@ -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))
......
......@@ -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)
......
......@@ -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)
......
......@@ -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
......@@ -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]
......
......@@ -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]
......
......@@ -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):
"""
......
......@@ -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'
......
......@@ -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}),
......
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
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('<li class="field full-width" id="add-coupon-modal-field-expiry"><input id="expiry-check" type="checkbox"/><label for="expiry-check"></label><input type="text" id="coupon_expiration_date" class="field" name="expiration_date" aria-required="true"/></li>')
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;');
});
});
});
......@@ -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',
......
......@@ -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 {
......
......@@ -49,6 +49,12 @@
<input class="field readonly" id="coupon_course_id" type="text" name="course_id" value="${section_data['course_id'] | h}"
readonly aria-required="true"/>
</li>
<li class="field full-width" id="add-coupon-modal-field-expiry">
<input id="expiry-check" type="checkbox" value="true" />
<label for="expiry-check">${_('Add expiration date')}</label>
<input type="text" id="coupon_expiration_date" value="" class="field" name="expiration_date"
aria-required="true"/>
</li>
</ol>
</fieldset>
......
<%! 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 @@
<tr class="coupons-headings">
<th class="c_code">${_("Code")}</th>
<th class="c_dsc">${_("Description")}</th>
<th class="c_expiry">${_("Expiry Date")}</th>
<th class="c_discount">${_("Discount (%)")}</th>
<th class="c_count">${_("Redeem Count")}</th>
<th class="c_action">${_("Actions")}</th>
......@@ -102,14 +105,21 @@
</thead>
<tbody>
%for coupon in section_data['coupons']:
<% current_date = datetime.now(pytz.UTC) %>
<% coupon_expiry_date = coupon.expiration_date %>
%if coupon.is_active == False:
<tr class="coupons-items inactive_coupon">
%else:
%elif coupon_expiry_date is not None and current_date >= coupon_expiry_date:
<tr class="coupons-items expired_coupon">
%else:
<tr class="coupons-items">
%endif
<td>${coupon.code}</td>
<td>${coupon.description}</td>
<td>${coupon.percentage_discount}</td>
<td>${_('{code}').format(code=coupon.code)}</td>
<td>${_('{description}').format(description=coupon.description)}</td>
<td>
${coupon.display_expiry_date}
</td>
<td>${_('{discount}').format(discount=coupon.percentage_discount)}</td>
<td>${ coupon.couponredemption_set.filter(order__status='purchased').count() }</td>
<td><a data-item-id="${coupon.id}" class='remove_coupon' href='#'>[x]</a><a href="#edit-modal" data-item-id="${coupon.id}" class="edit-right">${_('Edit')}</a></td>
</tr>
......@@ -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) {
......
......@@ -49,6 +49,12 @@
<input class="field readonly" id="edit_coupon_course_id" type="text" name="course_id" value=""
readonly aria-required="true"/>
</li>
<li class="field" id="edit-coupon-modal-field-expiration-date">
<label for="edit_coupon_expiration_date">${_("Expiration Date")}</label>
<input class="field readonly" id="edit_coupon_expiration_date" type="text" name="expiration_date" value=""
readonly aria-required="true"/>
</li>
</ol>
</fieldset>
......
......@@ -53,6 +53,7 @@
<%static:js group='application'/>
## Backbone classes declared explicitly until RequireJS is supported
<script type="text/javascript" src="${static.url('js/instructor_dashboard/ecommerce.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/notification.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/notification.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/file_uploader.js')}"></script>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment