Commit 246a4317 by chrisndodge

Merge pull request #4590 from edx/cdodge/fix-reg-codes

eCommerce enhancements (pt. 2)
parents 9a2c96ca 33bdf935
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseModesArchive'
db.create_table('course_modes_coursemodesarchive', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
('mode_slug', self.gf('django.db.models.fields.CharField')(max_length=100)),
('mode_display_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
('min_price', self.gf('django.db.models.fields.IntegerField')(default=0)),
('suggested_prices', self.gf('django.db.models.fields.CommaSeparatedIntegerField')(default='', max_length=255, blank=True)),
('currency', self.gf('django.db.models.fields.CharField')(default='usd', max_length=8)),
('expiration_date', self.gf('django.db.models.fields.DateField')(default=None, null=True, blank=True)),
('expiration_datetime', self.gf('django.db.models.fields.DateTimeField')(default=None, null=True, blank=True)),
))
db.send_create_signal('course_modes', ['CourseModesArchive'])
# Changing field 'CourseMode.course_id'
db.alter_column('course_modes_coursemode', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255))
def backwards(self, orm):
# Deleting model 'CourseModesArchive'
db.delete_table('course_modes_coursemodesarchive')
# Changing field 'CourseMode.course_id'
db.alter_column('course_modes_coursemode', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=255))
models = {
'course_modes.coursemode': {
'Meta': {'unique_together': "(('course_id', 'mode_slug', 'currency'),)", 'object_name': 'CourseMode'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'expiration_date': ('django.db.models.fields.DateField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'expiration_datetime': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
},
'course_modes.coursemodesarchive': {
'Meta': {'object_name': 'CourseModesArchive'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'expiration_date': ('django.db.models.fields.DateField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'expiration_datetime': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
}
}
complete_apps = ['course_modes']
\ No newline at end of file
......@@ -143,3 +143,35 @@ class CourseMode(models.Model):
return u"{} : {}, min={}, prices={}".format(
self.course_id.to_deprecated_string(), self.mode_slug, self.min_price, self.suggested_prices
)
class CourseModesArchive(models.Model):
"""
Store the past values of course_mode that a course had in the past. We decided on having
separate model, because there is a uniqueness contraint on (course_mode, course_id)
field pair in CourseModes. Having a separate table allows us to have an audit trail of any changes
such as course price changes
"""
# the course that this mode is attached to
course_id = CourseKeyField(max_length=255, db_index=True)
# the reference to this mode that can be used by Enrollments to generate
# similar behavior for the same slug across courses
mode_slug = models.CharField(max_length=100)
# The 'pretty' name that can be translated and displayed
mode_display_name = models.CharField(max_length=255)
# minimum price in USD that we would like to charge for this mode of the course
min_price = models.IntegerField(default=0)
# the suggested prices for this mode
suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='')
# the currency these prices are in, using lower case ISO currency codes
currency = models.CharField(default="usd", max_length=8)
# turn this mode off after the given expiration date
expiration_date = models.DateField(default=None, null=True, blank=True)
expiration_datetime = models.DateTimeField(default=None, null=True, blank=True)
......@@ -18,6 +18,8 @@ from pytz import UTC
import uuid
from collections import defaultdict
from dogapi import dog_stats_api
from django.db.models import Q
import pytz
from django.conf import settings
from django.utils import timezone
......@@ -960,6 +962,17 @@ class CourseEnrollment(models.Model):
d['total'] = total
return d
def is_paid_course(self):
"""
Returns True, if course is paid
"""
paid_course = CourseMode.objects.filter(Q(course_id=self.course_id) & Q(mode_slug='honor') &
(Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gte=datetime.now(pytz.UTC)))).exclude(min_price=0)
if paid_course:
return True
return False
def activate(self):
"""Makes this `CourseEnrollment` record active. Saves immediately."""
self.update_enrollment(is_active=True)
......@@ -991,6 +1004,8 @@ class CourseEnrollment(models.Model):
if GeneratedCertificate.certificate_for_student(self.user, self.course_id) is not None:
return False
#TODO - When Course administrators to define a refund period for paid courses then refundable will be supported. # pylint: disable=W0511
course_mode = CourseMode.mode_for_course(self.course_id, 'verified')
if course_mode is None:
return False
......
......@@ -148,11 +148,6 @@ class DashboardTest(TestCase):
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.assertIsNotNone(self.course)
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test')
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor Code',
)
self.client = Client()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
......@@ -231,6 +226,26 @@ class DashboardTest(TestCase):
self.assertFalse(enrollment.refundable())
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_refundable_of_purchased_course(self):
self.client.login(username="jack", password="test")
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
min_price=10,
currency='usd',
mode_display_name='honor',
expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1)
)
enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='honor')
# TODO: Until we can allow course administrators to define a refund period for paid for courses show_refund_option should be False. # pylint: disable=W0511
self.assertFalse(enrollment.refundable())
resp = self.client.post(reverse('student.views.dashboard', args=[]))
self.assertIn('You will not be refunded the amount you paid.', resp.content)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_refundable_when_certificate_exists(self):
verified_mode = CourseModeFactory.create(
course_id=self.course.id,
......
......@@ -483,6 +483,8 @@ def dashboard(request):
show_refund_option_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if _enrollment.refundable())
enrolled_courses_either_paid = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if _enrollment.is_paid_course())
# get info w.r.t ExternalAuthMap
external_auth_map = None
try:
......@@ -535,6 +537,7 @@ def dashboard(request):
'duplicate_provider': None,
'logout_url': reverse(logout_user),
'platform_name': settings.PLATFORM_NAME,
'enrolled_courses_either_paid': enrolled_courses_either_paid,
'provider_states': [],
}
......
......@@ -11,17 +11,15 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.models import CourseMode
from shoppingcart.models import Coupon, PaidCourseRegistration
from shoppingcart.models import Coupon, PaidCourseRegistration, CourseRegistrationCode
from mock import patch
from student.roles import CourseFinanceAdminRole
# pylint: disable=E1101
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestECommerceDashboardViews(ModuleStoreTestCase):
"""
Check for email view on the new instructor dashboard
for Mongo-backed courses
Check for E-commerce view on the new instructor dashboard
"""
def setUp(self):
self.course = CourseFactory.create()
......@@ -59,6 +57,7 @@ class TestECommerceDashboardViews(ModuleStoreTestCase):
# Total amount html should render in e-commerce page, total amount will be 0
total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course.id)
self.assertTrue('<span>Total Amount: <span>$' + str(total_amount) + '</span></span>' in response.content)
self.assertTrue('Download All e-Commerce Purchase' in response.content)
# removing the course finance_admin role of login user
CourseFinanceAdminRole(self.course.id).remove_users(self.instructor)
......@@ -67,8 +66,78 @@ class TestECommerceDashboardViews(ModuleStoreTestCase):
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
response = self.client.post(url)
total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course.id)
self.assertFalse('Download All e-Commerce Purchase' in response.content)
self.assertFalse('<span>Total Amount: <span>$' + str(total_amount) + '</span></span>' in response.content)
def test_user_view_course_price(self):
"""
test to check if the user views the set price button and price in
the instructor dashboard
"""
response = self.client.get(self.url)
self.assertTrue(self.e_commerce_link in response.content)
# Total amount html should render in e-commerce page, total amount will be 0
course_honor_mode = CourseMode.mode_for_course(self.course.id, 'honor')
price = course_honor_mode.min_price
self.assertTrue('Course Price: <span>$' + str(price) + '</span>' in response.content)
self.assertFalse('+ Set Price</a></span>' in response.content)
# removing the course finance_admin role of login user
CourseFinanceAdminRole(self.course.id).remove_users(self.instructor)
# total amount should not be visible in e-commerce page if the user is not finance admin
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
response = self.client.get(url)
self.assertFalse('+ Set Price</a></span>' in response.content)
def test_update_course_price_check(self):
price = 200
# course B
course2 = CourseFactory.create(org='EDX', display_name='test_course', number='100')
mode = CourseMode(
course_id=course2.id.to_deprecated_string(), mode_slug='honor',
mode_display_name='honor', min_price=30, currency='usd'
)
mode.save()
# course A update
CourseMode.objects.filter(course_id=self.course.id).update(min_price=price)
set_course_price_url = reverse('set_course_mode_price', kwargs={'course_id': self.course.id.to_deprecated_string()})
data = {'course_price': price, 'currency': 'usd'}
response = self.client.post(set_course_price_url, data)
self.assertTrue('CourseMode price updated successfully' in response.content)
# Course A updated total amount should be visible in e-commerce page if the user is finance admin
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
response = self.client.get(url)
self.assertTrue('Course Price: <span>$' + str(price) + '</span>' in response.content)
def test_user_admin_set_course_price(self):
"""
test to set the course price related functionality.
test al the scenarios for setting a new course price
"""
set_course_price_url = reverse('set_course_mode_price', kwargs={'course_id': self.course.id.to_deprecated_string()})
data = {'course_price': '12%', 'currency': 'usd'}
# Value Error course price should be a numeric value
response = self.client.post(set_course_price_url, data)
self.assertTrue("Please Enter the numeric value for the course price" in response.content)
# validation check passes and course price is successfully added
data['course_price'] = 100
response = self.client.post(set_course_price_url, data)
self.assertTrue("CourseMode price updated successfully" in response.content)
course_honor_mode = CourseMode.objects.get(mode_slug='honor')
course_honor_mode.delete()
# Course Mode not exist with mode slug honor
response = self.client.post(set_course_price_url, data)
self.assertTrue("CourseMode with the mode slug({mode_slug}) DoesNotExist".format(mode_slug='honor') in response.content)
def test_add_coupon(self):
"""
Test Add Coupon Scenarios. Handle all the HttpResponses return by add_coupon view
......@@ -105,6 +174,17 @@ class TestECommerceDashboardViews(ModuleStoreTestCase):
response = self.client.post(add_coupon_url, data=data)
self.assertTrue('Please Enter the Integer Value for Coupon Discount' in response.content)
course_registration = CourseRegistrationCode(
code='Vs23Ws4j', course_id=self.course.id.to_deprecated_string(),
transaction_group_name='Test Group', created_by=self.instructor
)
course_registration.save()
data['code'] = 'Vs23Ws4j'
response = self.client.post(add_coupon_url, data)
self.assertTrue("The code ({code}) that you have tried to define is already in use as a registration code"
.format(code=data['code']) in response.content)
def test_delete_coupon(self):
"""
Test Delete Coupon Scenarios. Handle all the HttpResponses return by remove_coupon view
......@@ -208,6 +288,18 @@ class TestECommerceDashboardViews(ModuleStoreTestCase):
percentage_discount=20, created_by=self.instructor
)
coupon1.save()
data = {'coupon_id': coupon.id, 'code': '11111', 'discount': '12'}
data = {'coupon_id': coupon.id, 'code': '11111', 'discount': '12'} # pylint: disable=E1101
response = self.client.post(update_coupon_url, data=data)
self.assertTrue('coupon with the coupon id ({coupon_id}) already exist'.format(coupon_id=coupon.id) in response.content) # pylint: disable=E1101
course_registration = CourseRegistrationCode(
code='Vs23Ws4j', course_id=self.course.id.to_deprecated_string(),
transaction_group_name='Test Group', created_by=self.instructor
)
course_registration.save()
data = {'coupon_id': coupon.id, 'code': 'Vs23Ws4j', # pylint: disable=E1101
'discount': '6', 'course_id': coupon.course_id.to_deprecated_string()} # pylint: disable=E1101
response = self.client.post(update_coupon_url, data=data)
self.assertTrue('coupon with the coupon id ({coupon_id}) already exist'.format(coupon_id=coupon.id) in response.content)
self.assertTrue("The code ({code}) that you have tried to define is already in use as a registration code".
format(code=data['code']) in response.content)
......@@ -5,6 +5,7 @@ JSON views which the instructor dashboard requests.
Many of these GETs may become PUTs in the future.
"""
from django.views.decorators.http import require_POST
import json
import logging
......@@ -14,11 +15,14 @@ from django.conf import settings
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.core.urlresolvers import reverse
from django.core.validators import validate_email
from django.utils.translation import ugettext as _
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.utils.html import strip_tags
import string # pylint: disable=W0402
import random
from util.json_request import JsonResponse
from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features
......@@ -34,6 +38,7 @@ from django_comment_common.models import (
)
from edxmako.shortcuts import render_to_response
from courseware.models import StudentModule
from shoppingcart.models import Coupon, CourseRegistrationCode, RegistrationCodeRedemption
from student.models import CourseEnrollment, unique_id_for_user, anonymous_id_for_user
import instructor_task.api
from instructor_task.api_helper import AlreadyRunningError
......@@ -550,6 +555,34 @@ def get_grading_config(request, course_id):
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def get_purchase_transaction(request, course_id, csv=False): # pylint: disable=W0613, W0621
"""
return the summary of all purchased transactions for a particular course
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
query_features = [
'id', 'username', 'email', 'course_id', 'list_price', 'coupon_code',
'unit_cost', 'purchase_time', 'orderitem_id',
'order_id',
]
student_data = instructor_analytics.basic.purchase_transactions(course_id, query_features)
if not csv:
response_payload = {
'course_id': course_id.to_deprecated_string(),
'students': student_data,
'queried_features': query_features
}
return JsonResponse(response_payload)
else:
header, datarows = instructor_analytics.csvs.format_dictlist(student_data, query_features)
return instructor_analytics.csvs.create_csv_response("e-commerce_purchase_transactions.csv", header, datarows)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def get_students_features(request, course_id, csv=False): # pylint: disable=W0613, W0621
"""
Respond with json which contains a summary of all enrolled students profile information.
......@@ -602,6 +635,155 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=W06
return instructor_analytics.csvs.create_csv_response("enrolled_profiles.csv", header, datarows)
def save_registration_codes(request, course_id, generated_codes_list, group_name):
"""
recursive function that generate a new code every time and saves in the Course Registration Table
if validation check passes
"""
code = random_code_generator()
# check if the generated code is in the Coupon Table
matching_coupons = Coupon.objects.filter(code=code, is_active=True)
if matching_coupons:
return save_registration_codes(request, course_id, generated_codes_list, group_name)
course_registration = CourseRegistrationCode(
code=code, course_id=course_id.to_deprecated_string(),
transaction_group_name=group_name, created_by=request.user
)
try:
course_registration.save()
generated_codes_list.append(course_registration)
except IntegrityError:
return save_registration_codes(request, course_id, generated_codes_list, group_name)
def registration_codes_csv(file_name, codes_list, csv_type=None):
"""
Respond with the csv headers and data rows
given a dict of codes list
:param file_name:
:param codes_list:
:param csv_type:
"""
# csv headers
query_features = ['code', 'course_id', 'transaction_group_name', 'created_by', 'redeemed_by']
registration_codes = instructor_analytics.basic.course_registration_features(query_features, codes_list, csv_type)
header, data_rows = instructor_analytics.csvs.format_dictlist(registration_codes, query_features)
return instructor_analytics.csvs.create_csv_response(file_name, header, data_rows)
def random_code_generator():
"""
generate a random alphanumeric code of length defined in
REGISTRATION_CODE_LENGTH settings
"""
chars = string.ascii_uppercase + string.digits + string.ascii_lowercase
code_length = getattr(settings, 'REGISTRATION_CODE_LENGTH', 8)
return string.join((random.choice(chars) for _ in range(code_length)), '')
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_POST
def get_registration_codes(request, course_id): # pylint: disable=W0613
"""
Respond with csv which contains a summary of all Registration Codes.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
#filter all the course registration codes
registration_codes = CourseRegistrationCode.objects.filter(course_id=course_id).order_by('transaction_group_name')
group_name = request.POST['download_transaction_group_name']
if group_name:
registration_codes = registration_codes.filter(transaction_group_name=group_name)
csv_type = 'download'
return registration_codes_csv("Registration_Codes.csv", registration_codes, csv_type)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_POST
def generate_registration_codes(request, course_id):
"""
Respond with csv which contains a summary of all Generated Codes.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course_registration_codes = []
# covert the course registration code number into integer
try:
course_code_number = int(request.POST['course_registration_code_number'])
except ValueError:
course_code_number = int(float(request.POST['course_registration_code_number']))
group_name = request.POST['transaction_group_name']
for _ in range(course_code_number): # pylint: disable=W0621
save_registration_codes(request, course_id, course_registration_codes, group_name)
return registration_codes_csv("Registration_Codes.csv", course_registration_codes)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_POST
def active_registration_codes(request, course_id): # pylint: disable=W0613
"""
Respond with csv which contains a summary of all Active Registration Codes.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
# find all the registration codes in this course
registration_codes_list = CourseRegistrationCode.objects.filter(course_id=course_id).order_by('transaction_group_name')
group_name = request.POST['active_transaction_group_name']
if group_name:
registration_codes_list = registration_codes_list.filter(transaction_group_name=group_name)
# find the redeemed registration codes if any exist in the db
code_redemption_set = RegistrationCodeRedemption.objects.select_related('registration_code').filter(registration_code__course_id=course_id)
if code_redemption_set.exists():
redeemed_registration_codes = [code.registration_code.code for code in code_redemption_set]
# exclude the redeemed registration codes from the registration codes list and you will get
# all the registration codes that are active
registration_codes_list = registration_codes_list.exclude(code__in=redeemed_registration_codes)
return registration_codes_csv("Active_Registration_Codes.csv", registration_codes_list)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_POST
def spent_registration_codes(request, course_id): # pylint: disable=W0613
"""
Respond with csv which contains a summary of all Spent(used) Registration Codes.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
# find the redeemed registration codes if any exist in the db
code_redemption_set = RegistrationCodeRedemption.objects.select_related('registration_code').filter(registration_code__course_id=course_id)
spent_codes_list = []
if code_redemption_set.exists():
redeemed_registration_codes = [code.registration_code.code for code in code_redemption_set]
# filter the Registration Codes by course id and the redeemed codes and
# you will get a list of all the spent(Redeemed) Registration Codes
spent_codes_list = CourseRegistrationCode.objects.filter(course_id=course_id, code__in=redeemed_registration_codes).order_by('transaction_group_name')
group_name = request.POST['spent_transaction_group_name']
if group_name:
spent_codes_list = spent_codes_list.filter(transaction_group_name=group_name) # pylint: disable=E1103
csv_type = 'spent'
return registration_codes_csv("Spent_Registration_Codes.csv", spent_codes_list, csv_type)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
......
......@@ -17,6 +17,8 @@ urlpatterns = patterns('', # nopep8
'instructor.views.api.get_grading_config', name="get_grading_config"),
url(r'^get_students_features(?P<csv>/csv)?$',
'instructor.views.api.get_students_features', name="get_students_features"),
url(r'^get_purchase_transaction(?P<csv>/csv)?$',
'instructor.views.api.get_purchase_transaction', name="get_purchase_transaction"),
url(r'^get_anon_ids$',
'instructor.views.api.get_anon_ids', name="get_anon_ids"),
url(r'^get_distribution$',
......@@ -56,6 +58,16 @@ urlpatterns = patterns('', # nopep8
url(r'calculate_grades_csv$',
'instructor.views.api.calculate_grades_csv', name="calculate_grades_csv"),
# Registration Codes..
url(r'get_registration_codes$',
'instructor.views.api.get_registration_codes', name="get_registration_codes"),
url(r'generate_registration_codes$',
'instructor.views.api.generate_registration_codes', name="generate_registration_codes"),
url(r'active_registration_codes$',
'instructor.views.api.active_registration_codes', name="active_registration_codes"),
url(r'spent_registration_codes$',
'instructor.views.api.spent_registration_codes', name="spent_registration_codes"),
# spoc gradebook
url(r'^gradebook$',
'instructor.views.api.spoc_gradebook', name='spoc_gradebook'),
......
......@@ -8,7 +8,7 @@ from django.views.decorators.http import require_POST
from django.utils.translation import ugettext as _
from util.json_request import JsonResponse
from django.http import HttpResponse, HttpResponseNotFound
from shoppingcart.models import Coupon
from shoppingcart.models import Coupon, CourseRegistrationCode
import logging
......@@ -59,6 +59,13 @@ def add_coupon(request, course_id): # pylint: disable=W0613
if coupon:
return HttpResponseNotFound(_("coupon with the coupon code ({code}) already exist").format(code=code))
# check if the coupon code is in the CourseRegistrationCode Table
course_registration_code = CourseRegistrationCode.objects.filter(code=code)
if course_registration_code:
return HttpResponseNotFound(_(
"The code ({code}) that you have tried to define is already in use as a registration code").format(code=code)
)
description = request.POST.get('description')
course_id = request.POST.get('course_id')
try:
......@@ -96,6 +103,13 @@ def update_coupon(request, course_id): # pylint: disable=W0613
if filtered_coupons:
return HttpResponseNotFound(_("coupon with the coupon id ({coupon_id}) already exists").format(coupon_id=coupon_id))
# check if the coupon code is in the CourseRegistrationCode Table
course_registration_code = CourseRegistrationCode.objects.filter(code=code)
if course_registration_code:
return HttpResponseNotFound(_(
"The code ({code}) that you have tried to define is already in use as a registration code").format(code=code)
)
description = request.POST.get('description')
course_id = request.POST.get('course_id')
try:
......
"""
Instructor Dashboard Views
"""
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
import logging
import datetime
import pytz
from django.utils.translation import ugettext as _
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
......@@ -11,7 +14,7 @@ from edxmako.shortcuts import render_to_response
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.utils.html import escape
from django.http import Http404
from django.http import Http404, HttpResponse, HttpResponseNotFound
from django.conf import settings
from lms.lib.xblock.runtime import quote_slashes
......@@ -27,7 +30,7 @@ from django_comment_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from student.models import CourseEnrollment
from shoppingcart.models import Coupon, PaidCourseRegistration
from course_modes.models import CourseMode
from course_modes.models import CourseMode, CourseModesArchive
from student.roles import CourseFinanceAdminRole
from bulk_email.models import CourseAuthorization
......@@ -130,6 +133,10 @@ def _section_e_commerce(course_key, access):
""" Provide data for the corresponding dashboard section """
coupons = Coupon.objects.filter(course_id=course_key).order_by('-is_active')
total_amount = None
course_price = None
course_honor_mode = CourseMode.mode_for_course(course_key, 'honor')
if course_honor_mode and course_honor_mode.min_price > 0:
course_price = course_honor_mode.min_price
if access['finance_admin']:
total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(course_key)
......@@ -142,13 +149,52 @@ def _section_e_commerce(course_key, access):
'ajax_get_coupon_info': reverse('get_coupon_info', kwargs={'course_id': course_key.to_deprecated_string()}),
'ajax_update_coupon': reverse('update_coupon', kwargs={'course_id': course_key.to_deprecated_string()}),
'ajax_add_coupon': reverse('add_coupon', kwargs={'course_id': course_key.to_deprecated_string()}),
'get_purchase_transaction_url': reverse('get_purchase_transaction', kwargs={'course_id': course_key.to_deprecated_string()}),
'instructor_url': reverse('instructor_dashboard', kwargs={'course_id': course_key.to_deprecated_string()}),
'get_registration_code_csv_url': reverse('get_registration_codes', kwargs={'course_id': course_key.to_deprecated_string()}),
'generate_registration_code_csv_url': reverse('generate_registration_codes', kwargs={'course_id': course_key.to_deprecated_string()}),
'active_registration_code_csv_url': reverse('active_registration_codes', kwargs={'course_id': course_key.to_deprecated_string()}),
'spent_registration_code_csv_url': reverse('spent_registration_codes', kwargs={'course_id': course_key.to_deprecated_string()}),
'set_course_mode_url': reverse('set_course_mode_price', kwargs={'course_id': course_key.to_deprecated_string()}),
'coupons': coupons,
'total_amount': total_amount,
'course_price': course_price
}
return section_data
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_POST
@login_required
def set_course_mode_price(request, course_id):
"""
set the new course price and add new entry in the CourseModesArchive Table
"""
try:
course_price = int(request.POST['course_price'])
except ValueError:
return HttpResponseNotFound(_("Please Enter the numeric value for the course price"))
currency = request.POST['currency']
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course_honor_mode = CourseMode.objects.filter(mode_slug='honor', course_id=course_key)
if not course_honor_mode:
return HttpResponseNotFound(
_("CourseMode with the mode slug({mode_slug}) DoesNotExist").format(mode_slug='honor')
)
CourseModesArchive.objects.create(
course_id=course_id, mode_slug='honor', mode_display_name='Honor Code Certificate',
min_price=getattr(course_honor_mode[0], 'min_price'), currency=getattr(course_honor_mode[0], 'currency'),
expiration_datetime=datetime.datetime.now(pytz.utc), expiration_date=datetime.date.today()
)
course_honor_mode.update(
min_price=course_price,
currency=currency
)
return HttpResponse(_("CourseMode price updated successfully"))
def _section_course_info(course_key, access):
""" Provide data for the corresponding dashboard section """
course = get_course_by_id(course_key, depth=None)
......
......@@ -3,15 +3,68 @@ Student and course analytics.
Serve miscellaneous course and student data
"""
from shoppingcart.models import PaidCourseRegistration, CouponRedemption
from django.contrib.auth.models import User
import xmodule.graders as xmgraders
from django.core.exceptions import ObjectDoesNotExist
STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email')
PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender',
'level_of_education', 'mailing_address', 'goals')
ORDER_ITEM_FEATURES = ('list_price', 'unit_cost', 'order_id')
ORDER_FEATURES = ('purchase_time',)
AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES
COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'transaction_group_name', 'created_by')
def purchase_transactions(course_id, features):
"""
Return list of purchased transactions features as dictionaries.
purchase_transactions(course_id, ['username, email', unit_cost])
would return [
{'username': 'username1', 'email': 'email1', unit_cost:'cost1 in decimal'.}
{'username': 'username2', 'email': 'email2', unit_cost:'cost2 in decimal'.}
{'username': 'username3', 'email': 'email3', unit_cost:'cost3 in decimal'.}
]
"""
purchased_courses = PaidCourseRegistration.objects.filter(course_id=course_id, status='purchased')
def purchase_transactions_info(purchased_course, features):
""" convert purchase transactions to dictionary """
coupon_code_dict = dict()
student_features = [x for x in STUDENT_FEATURES if x in features]
order_features = [x for x in ORDER_FEATURES if x in features]
order_item_features = [x for x in ORDER_ITEM_FEATURES if x in features]
# Extracting user information
student_dict = dict((feature, getattr(purchased_course.user, feature))
for feature in student_features)
# Extracting Order information
order_dict = dict((feature, getattr(purchased_course.order, feature))
for feature in order_features)
# Extracting OrderItem information
order_item_dict = dict((feature, getattr(purchased_course, feature))
for feature in order_item_features)
order_item_dict.update({"orderitem_id": getattr(purchased_course, 'id')})
try:
coupon_redemption = CouponRedemption.objects.select_related('coupon').get(order_id=purchased_course.order_id)
except CouponRedemption.DoesNotExist:
coupon_code_dict = {'coupon_code': 'None'}
else:
coupon_code_dict = {'coupon_code': coupon_redemption.coupon.code}
student_dict.update(dict(order_dict.items() + order_item_dict.items() + coupon_code_dict.items()))
student_dict.update({'course_id': course_id.to_deprecated_string()})
return student_dict
return [purchase_transactions_info(purchased_course, features) for purchased_course in purchased_courses]
def enrolled_students_features(course_id, features):
......@@ -47,6 +100,42 @@ def enrolled_students_features(course_id, features):
return [extract_student(student, features) for student in students]
def course_registration_features(features, registration_codes, csv_type):
"""
Return list of Course Registration Codes as dictionaries.
course_registration_features
would return [
{'code': 'code1', 'course_id': 'edX/Open_DemoX/edx_demo_course, ..... }
{'code': 'code2', 'course_id': 'edX/Open_DemoX/edx_demo_course, ..... }
]
"""
def extract_course_registration(registration_code, features, csv_type):
""" convert registration_code to dictionary
:param registration_code:
:param features:
:param csv_type:
"""
registration_features = [x for x in COURSE_REGISTRATION_FEATURES if x in features]
course_registration_dict = dict((feature, getattr(registration_code, feature)) for feature in registration_features)
course_registration_dict['redeemed_by'] = None
# we have to capture the redeemed_by value in the case of the downloading and spent registration
# codes csv. In the case of active and generated registration codes the redeemed_by value will be None.
# They have not been redeemed yet
if csv_type is not None:
try:
course_registration_dict['redeemed_by'] = getattr(registration_code.registrationcoderedemption_set.get(registration_code=registration_code), 'redeemed_by')
except ObjectDoesNotExist:
pass
course_registration_dict['course_id'] = course_registration_dict['course_id'].to_deprecated_string()
return course_registration_dict
return [extract_course_registration(code, features, csv_type) for code in registration_codes]
def dump_grading_context(course):
"""
Render information about course grading context
......
......@@ -6,8 +6,9 @@ from django.test import TestCase
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from shoppingcart.models import CourseRegistrationCode, RegistrationCodeRedemption, Order
from instructor_analytics.basic import enrolled_students_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES
from instructor_analytics.basic import enrolled_students_features, course_registration_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES
class TestAnalyticsBasic(TestCase):
......@@ -42,3 +43,34 @@ class TestAnalyticsBasic(TestCase):
def test_available_features(self):
self.assertEqual(len(AVAILABLE_FEATURES), len(STUDENT_FEATURES + PROFILE_FEATURES))
self.assertEqual(set(AVAILABLE_FEATURES), set(STUDENT_FEATURES + PROFILE_FEATURES))
def test_course_registration_features(self):
query_features = ['code', 'course_id', 'transaction_group_name', 'created_by', 'redeemed_by']
for i in range(5):
course_code = CourseRegistrationCode(
code="test_code{}".format(i), course_id=self.course_key.to_deprecated_string(),
transaction_group_name='TestName', created_by=self.users[0]
)
course_code.save()
order = Order(user=self.users[0], status='purchased')
order.save()
registration_code_redemption = RegistrationCodeRedemption(
order=order, registration_code_id=1, redeemed_by=self.users[0]
)
registration_code_redemption.save()
registration_codes = CourseRegistrationCode.objects.all()
course_registration_list = course_registration_features(query_features, registration_codes, csv_type='download')
self.assertEqual(len(course_registration_list), len(registration_codes))
for course_registration in course_registration_list:
self.assertEqual(set(course_registration.keys()), set(query_features))
self.assertIn(course_registration['code'], [registration_code.code for registration_code in registration_codes])
self.assertIn(
course_registration['course_id'],
[registration_code.course_id.to_deprecated_string() for registration_code in registration_codes]
)
self.assertIn(
course_registration['transaction_group_name'],
[registration_code.transaction_group_name for registration_code in registration_codes]
)
......@@ -2,6 +2,50 @@
Allows django admin site to add PaidCourseRegistrationAnnotations
"""
from ratelimitbackend import admin
from shoppingcart.models import PaidCourseRegistrationAnnotation
from shoppingcart.models import PaidCourseRegistrationAnnotation, Coupon
class SoftDeleteCouponAdmin(admin.ModelAdmin):
"""
Admin for the Coupon table.
soft-delete on the coupons
"""
fields = ('code', 'description', 'course_id', 'percentage_discount', 'created_by', 'created_at', 'is_active')
raw_id_fields = ("created_by",)
readonly_fields = ('created_at',)
actions = ['really_delete_selected']
def queryset(self, request):
""" Returns a QuerySet of all model instances that can be edited by the
admin site. This is used by changelist_view. """
# Default: qs = self.model._default_manager.get_active_coupons_query_set()
# Queryset with all the coupons including the soft-deletes: qs = self.model._default_manager.get_query_set()
query_string = self.model._default_manager.get_active_coupons_query_set() # pylint: disable=W0212
return query_string
def get_actions(self, request):
actions = super(SoftDeleteCouponAdmin, self).get_actions(request)
del actions['delete_selected']
return actions
def really_delete_selected(self, request, queryset):
"""override the default behavior of selected delete method"""
for obj in queryset:
obj.is_active = False
obj.save()
if queryset.count() == 1:
message_bit = "1 coupon entry was"
else:
message_bit = "%s coupon entries were" % queryset.count()
self.message_user(request, "%s successfully deleted." % message_bit)
def delete_model(self, request, obj):
"""override the default behavior of single instance of model delete method"""
obj.is_active = False
obj.save()
really_delete_selected.short_description = "Delete s selected entries"
admin.site.register(PaidCourseRegistrationAnnotation)
admin.site.register(Coupon, SoftDeleteCouponAdmin)
......@@ -40,6 +40,14 @@ class ItemDoesNotExistAgainstCouponException(InvalidCartItem):
pass
class RegCodeAlreadyExistException(InvalidCartItem):
pass
class ItemDoesNotExistAgainstRegCodeException(InvalidCartItem):
pass
class ReportException(Exception):
pass
......
......@@ -31,7 +31,9 @@ from xmodule_django.models import CourseKeyField
from verify_student.models import SoftwareSecurePhotoVerification
from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException,
AlreadyEnrolledInCourseException, CourseDoesNotExistException, CouponAlreadyExistException, ItemDoesNotExistAgainstCouponException)
AlreadyEnrolledInCourseException, CourseDoesNotExistException,
CouponAlreadyExistException, ItemDoesNotExistAgainstCouponException,
RegCodeAlreadyExistException, ItemDoesNotExistAgainstRegCodeException)
from microsite_configuration import microsite
......@@ -320,14 +322,86 @@ class CourseRegistrationCode(models.Model):
This table contains registration codes
With registration code, a user can register for a course for free
"""
code = models.CharField(max_length=32, db_index=True)
code = models.CharField(max_length=32, db_index=True, unique=True)
course_id = CourseKeyField(max_length=255, db_index=True)
transaction_group_name = models.CharField(max_length=255, db_index=True, null=True, blank=True)
created_by = models.ForeignKey(User, related_name='created_by_user')
created_at = models.DateTimeField(default=datetime.now(pytz.utc))
redeemed_by = models.ForeignKey(User, null=True, related_name='redeemed_by_user')
@classmethod
@transaction.commit_on_success
def free_user_enrollment(cls, cart):
"""
Here we enroll the user free for all courses available in shopping cart
"""
cart_items = cart.orderitem_set.all().select_subclasses()
if cart_items:
for item in cart_items:
CourseEnrollment.enroll(cart.user, item.course_id)
log.info("Enrolled '{0}' in free course '{1}'"
.format(cart.user.email, item.course_id)) # pylint: disable=E1101
item.status = 'purchased'
item.save()
cart.status = 'purchased'
cart.purchase_time = datetime.now(pytz.utc)
cart.save()
class RegistrationCodeRedemption(models.Model):
"""
This model contains the registration-code redemption info
"""
order = models.ForeignKey(Order, db_index=True)
registration_code = models.ForeignKey(CourseRegistrationCode, db_index=True)
redeemed_by = models.ForeignKey(User, db_index=True)
redeemed_at = models.DateTimeField(default=datetime.now(pytz.utc), null=True)
@classmethod
def add_reg_code_redemption(cls, course_reg_code, order):
"""
add course registration code info into RegistrationCodeRedemption model
"""
cart_items = order.orderitem_set.all().select_subclasses()
for item in cart_items:
if getattr(item, 'course_id'):
if item.course_id == course_reg_code.course_id:
# If another account tries to use a existing registration code before the student checks out, an
# error message will appear.The reg code is un-reusable.
code_redemption = cls.objects.filter(registration_code=course_reg_code)
if code_redemption:
log.exception("Registration code '{0}' already used".format(course_reg_code.code))
raise RegCodeAlreadyExistException
code_redemption = RegistrationCodeRedemption(registration_code=course_reg_code, order=order, redeemed_by=order.user)
code_redemption.save()
item.list_price = item.unit_cost
item.unit_cost = 0
item.save()
log.info("Code '{0}' is used by user {1} against order id '{2}' "
.format(course_reg_code.code, order.user.username, order.id))
return course_reg_code
log.warning("Course item does not exist against registration code '{0}'".format(course_reg_code.code))
raise ItemDoesNotExistAgainstRegCodeException
class SoftDeleteCouponManager(models.Manager):
""" Use this manager to get objects that have a is_active=True """
def get_active_coupons_query_set(self):
"""
filter the is_active = True Coupons only
"""
return super(SoftDeleteCouponManager, self).get_query_set().filter(is_active=True)
def get_query_set(self):
"""
get all the coupon objects
"""
return super(SoftDeleteCouponManager, self).get_query_set()
class Coupon(models.Model):
"""
......@@ -342,6 +416,11 @@ class Coupon(models.Model):
created_at = models.DateTimeField(default=datetime.now(pytz.utc))
is_active = models.BooleanField(default=True)
def __unicode__(self):
return "[Coupon] code: {} course: {}".format(self.code, self.course_id)
objects = SoftDeleteCouponManager()
class CouponRedemption(models.Model):
"""
......
......@@ -14,7 +14,8 @@ if settings.FEATURES['ENABLE_SHOPPING_CART']:
url(r'^clear/$', 'clear_cart'),
url(r'^remove_item/$', 'remove_item'),
url(r'^add/course/{}/$'.format(settings.COURSE_ID_PATTERN), 'add_course_to_cart', name='add_course_to_cart'),
url(r'^use_coupon/$', 'use_coupon'),
url(r'^use_code/$', 'use_code'),
url(r'^register_courses/$', 'register_courses'),
)
if settings.FEATURES.get('ENABLE_PAYMENT_FAKE'):
......
......@@ -14,8 +14,9 @@ from edxmako.shortcuts import render_to_response
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from shoppingcart.reports import RefundReport, ItemizedPurchaseReport, UniversityRevenueShareReport, CertificateStatusReport
from student.models import CourseEnrollment
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException, CouponAlreadyExistException, ItemDoesNotExistAgainstCouponException
from .models import Order, PaidCourseRegistration, OrderItem, Coupon, CouponRedemption
from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException, \
CouponAlreadyExistException, ItemDoesNotExistAgainstCouponException, RegCodeAlreadyExistException, ItemDoesNotExistAgainstRegCodeException
from .models import Order, PaidCourseRegistration, OrderItem, Coupon, CouponRedemption, CourseRegistrationCode, RegistrationCodeRedemption
from .processors import process_postpay_callback, render_purchase_form_html
import json
......@@ -97,6 +98,11 @@ def clear_cart(request):
coupon_redemption.delete()
log.info('Coupon redemption entry removed for user {0} for order {1}'.format(request.user, cart.id))
reg_code_redemption = RegistrationCodeRedemption.objects.filter(redeemed_by=request.user, order=cart.id)
if reg_code_redemption:
reg_code_redemption.delete()
log.info('Registration code redemption entry removed for user {0} for order {1}'.format(request.user, cart.id))
return HttpResponse('Cleared')
......@@ -111,43 +117,101 @@ def remove_item(request):
order_item_course_id = item.paidcourseregistration.course_id
item.delete()
log.info('order item {0} removed for user {1}'.format(item_id, request.user))
try:
coupon_redemption = CouponRedemption.objects.get(user=request.user, order=item.order_id)
if order_item_course_id == coupon_redemption.coupon.course_id:
coupon_redemption.delete()
log.info('Coupon "{0}" redemption entry removed for user "{1}" for order item "{2}"'
.format(coupon_redemption.coupon.code, request.user, item_id))
except CouponRedemption.DoesNotExist:
log.debug('Coupon redemption does not exist for order item id={0}.'.format(item_id))
remove_code_redemption(order_item_course_id, item_id, item, request.user)
except OrderItem.DoesNotExist:
log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id))
return HttpResponse('OK')
def remove_code_redemption(order_item_course_id, item_id, item, user):
"""
If an item removed from shopping cart then we will remove
the corresponding redemption info of coupon/registration code.
"""
try:
# Try to remove redemption information of coupon code, If exist.
coupon_redemption = CouponRedemption.objects.get(user=user, order=item.order_id)
except CouponRedemption.DoesNotExist:
try:
# Try to remove redemption information of registration code, If exist.
reg_code_redemption = RegistrationCodeRedemption.objects.get(redeemed_by=user, order=item.order_id)
except RegistrationCodeRedemption.DoesNotExist:
log.debug('Code redemption does not exist for order item id={0}.'.format(item_id))
else:
if order_item_course_id == reg_code_redemption.registration_code.course_id:
reg_code_redemption.delete()
log.info('Registration code "{0}" redemption entry removed for user "{1}" for order item "{2}"'
.format(reg_code_redemption.registration_code.code, user, item_id))
else:
if order_item_course_id == coupon_redemption.coupon.course_id:
coupon_redemption.delete()
log.info('Coupon "{0}" redemption entry removed for user "{1}" for order item "{2}"'
.format(coupon_redemption.coupon.code, user, item_id))
@login_required
def use_coupon(request):
def use_code(request):
"""
This method generate discount against valid coupon code and save its entry into coupon redemption table
This method may generate the discount against valid coupon code
and save its entry into coupon redemption table
OR
Make the cart item free of cost against valid registration code.
Valid Code can be either coupon or registration code.
"""
coupon_code = request.POST["coupon_code"]
code = request.POST["code"]
try:
coupon = Coupon.objects.get(code=coupon_code)
coupon = Coupon.objects.get(code=code, is_active=True)
except Coupon.DoesNotExist:
return HttpResponseNotFound(_("Discount does not exist against coupon '{0}'.".format(coupon_code)))
if coupon.is_active:
# If not coupon code then we check that code against course registration code
try:
cart = Order.get_cart_for_user(request.user)
CouponRedemption.add_coupon_redemption(coupon, cart)
except CouponAlreadyExistException:
return HttpResponseBadRequest(_("Coupon '{0}' already used.".format(coupon_code)))
except ItemDoesNotExistAgainstCouponException:
return HttpResponseNotFound(_("Coupon '{0}' is not valid for any course in the shopping cart.".format(coupon_code)))
response = HttpResponse(json.dumps({'response': 'success'}), content_type="application/json")
return response
else:
return HttpResponseBadRequest(_("Coupon '{0}' is inactive.".format(coupon_code)))
course_reg = CourseRegistrationCode.objects.get(code=code)
except CourseRegistrationCode.DoesNotExist:
return HttpResponseNotFound(_("Discount does not exist against code '{0}'.".format(code)))
return use_registration_code(course_reg, request.user)
return use_coupon_code(coupon, request.user)
def use_registration_code(course_reg, user):
"""
This method utilize course registration code
"""
try:
cart = Order.get_cart_for_user(user)
RegistrationCodeRedemption.add_reg_code_redemption(course_reg, cart)
except RegCodeAlreadyExistException:
return HttpResponseBadRequest(_("Oops! The code '{0}' you entered is either invalid or expired".format(course_reg.code)))
except ItemDoesNotExistAgainstRegCodeException:
return HttpResponseNotFound(_("Code '{0}' is not valid for any course in the shopping cart.".format(course_reg.code)))
return HttpResponse(json.dumps({'response': 'success'}), content_type="application/json")
def use_coupon_code(coupon, user):
"""
This method utilize course coupon code
"""
try:
cart = Order.get_cart_for_user(user)
CouponRedemption.add_coupon_redemption(coupon, cart)
except CouponAlreadyExistException:
return HttpResponseBadRequest(_("Coupon '{0}' already used.".format(coupon.code)))
except ItemDoesNotExistAgainstCouponException:
return HttpResponseNotFound(_("Coupon '{0}' is not valid for any course in the shopping cart.".format(coupon.code)))
return HttpResponse(json.dumps({'response': 'success'}), content_type="application/json")
@login_required
def register_courses(request):
"""
This method enroll the user for available course(s)
in cart on which valid registration code is applied
"""
cart = Order.get_cart_for_user(request.user)
CourseRegistrationCode.free_user_enrollment(cart)
return HttpResponse(json.dumps({'response': 'success'}), content_type="application/json")
@csrf_exempt
......
......@@ -429,3 +429,6 @@ ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {})
##### GOOGLE ANALYTICS IDS #####
GOOGLE_ANALYTICS_ACCOUNT = AUTH_TOKENS.get('GOOGLE_ANALYTICS_ACCOUNT')
GOOGLE_ANALYTICS_LINKEDIN = AUTH_TOKENS.get('GOOGLE_ANALYTICS_LINKEDIN')
#### Course Registration Code length ####
REGISTRATION_CODE_LENGTH = ENV_TOKENS.get('REGISTRATION_CODE_LENGTH', 8)
......@@ -291,6 +291,9 @@ FEATURES['ENABLE_SHOPPING_CART'] = True
### This enables the Metrics tab for the Instructor dashboard ###########
FEATURES['CLASS_DASHBOARD'] = True
### This settings is for the course registration code length ############
REGISTRATION_CODE_LENGTH = 8
#####################################################################
# Lastly, see if the developer has any local overrides.
try:
......
###
E-Commerce Section
###
class ECommerce
# E-Commerce Section
constructor: (@$section) ->
# attach self to html so that instructor_dashboard.coffee can find
# this object to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @
# gather elements
@$list_purchase_csv_btn = @$section.find("input[name='list-purchase-transaction-csv']'")
@$transaction_group_name = @$section.find("input[name='transaction_group_name']'")
@$course_registration_number = @$section.find("input[name='course_registration_code_number']'")
@$download_transaction_group_name = @$section.find("input[name='download_transaction_group_name']'")
@$active_transaction_group_name = @$section.find("input[name='active_transaction_group_name']'")
@$spent_transaction_group_name = @$section.find('input[name="spent_transaction_group_name"]')
@$generate_registration_code_form = @$section.find("form#course_codes_number")
@$download_registration_codes_form = @$section.find("form#download_registration_codes")
@$active_registration_codes_form = @$section.find("form#active_registration_codes")
@$spent_registration_codes_form = @$section.find("form#spent_registration_codes")
@$coupoon_error = @$section.find('#coupon-error')
@$course_code_error = @$section.find('#code-error')
# attach click handlers
# this handler binds to both the download
# and the csv button
@$list_purchase_csv_btn.click (e) =>
url = @$list_purchase_csv_btn.data 'endpoint'
url += '/csv'
location.href = url
@$download_registration_codes_form.submit (e) =>
@$course_code_error.attr('style', 'display: none')
@$coupoon_error.attr('style', 'display: none')
return true
@$active_registration_codes_form.submit (e) =>
@$course_code_error.attr('style', 'display: none')
@$coupoon_error.attr('style', 'display: none')
return true
@$spent_registration_codes_form.submit (e) =>
@$course_code_error.attr('style', 'display: none')
@$coupoon_error.attr('style', 'display: none')
return true
@$generate_registration_code_form.submit (e) =>
@$course_code_error.attr('style', 'display: none')
@$coupoon_error.attr('style', 'display: none')
group_name = @$transaction_group_name.val()
if group_name == ''
@$course_code_error.html('Please Enter the Transaction Group Name').show()
return false
if ($.isNumeric(group_name))
@$course_code_error.html('Please Enter the non-numeric value for Transaction Group Name').show()
return false;
registration_codes = @$course_registration_number.val();
if (isInt(registration_codes) && $.isNumeric(registration_codes))
if (parseInt(registration_codes) > 1000 )
@$course_code_error.html('You can only generate 1000 Registration Codes at a time').show()
return false;
if (parseInt(registration_codes) == 0 )
@$course_code_error.html('Please Enter the Value greater than 0 for Registration Codes').show()
return false;
return true;
else
@$course_code_error.html('Please Enter the Integer Value for Registration Codes').show()
return false;
# handler for when the section title is clicked.
onClickTitle: ->
@clear_display()
# handler for when the section title is clicked.
onClickTitle: -> @clear_display()
# handler for when the section is closed
onExit: -> @clear_display()
clear_display: ->
@$course_code_error.attr('style', 'display: none')
@$coupoon_error.attr('style', 'display: none')
@$course_registration_number.val('')
@$transaction_group_name.val('')
@$download_transaction_group_name.val('')
@$active_transaction_group_name.val('')
@$spent_transaction_group_name.val('')
isInt = (n) -> return n % 1 == 0;
# Clear any generated tables, warning messages, etc.
# export for use
# create parent namespaces if they do not already exist.
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
ECommerce: ECommerce
\ No newline at end of file
......@@ -156,6 +156,9 @@ setup_instructor_dashboard_sections = (idash_content) ->
constructor: window.InstructorDashboard.sections.DataDownload
$element: idash_content.find ".#{CSS_IDASH_SECTION}#data_download"
,
constructor: window.InstructorDashboard.sections.ECommerce
$element: idash_content.find ".#{CSS_IDASH_SECTION}#e-commerce"
,
constructor: window.InstructorDashboard.sections.Membership
$element: idash_content.find ".#{CSS_IDASH_SECTION}#membership"
,
......
......@@ -829,6 +829,19 @@ input[name="subject"] {
.content{
padding: 0 !important;
}
input[name="course_registration_code_number"] {
margin-right: 10px;
height: 34px;
width: 258px;
border-radius: 3px;
}
input[name="transaction_group_name"], input[name="download_transaction_group_name"],
input[name="active_transaction_group_name"], input[name="spent_transaction_group_name"] {
margin-right: 8px;
height: 36px;
width: 300px;
border-radius: 3px;
}
.coupons-table {
width: 100%;
tr:nth-child(even){
......@@ -931,8 +944,8 @@ input[name="subject"] {
}
}
// coupon edit and add modals
#add-coupon-modal, #edit-coupon-modal{
// coupon edit and add modals
#add-coupon-modal, #edit-coupon-modal, #set-course-mode-price-modal{
.inner-wrapper {
background: #fff;
}
......@@ -948,6 +961,10 @@ input[name="subject"] {
@include button(simple, $blue);
@extend .button-reset;
}
input[type="submit"]#set_course_button{
@include button(simple, $blue);
@extend .button-reset;
}
.modal-form-error {
box-shadow: inset 0 -1px 2px 0 #f3d9db;
-webkit-box-sizing: border-box;
......@@ -984,9 +1001,21 @@ input[name="subject"] {
li:last-child{
margin-bottom: 0px !important;
}
}
#coupon-content {
li#set-course-mode-modal-field-price{
width: 100%;
label.required:after {
content: "*";
margin-left: 5px;
}
}
li#set-course-mode-modal-field-currency{
margin-left: 0px !important;
select {
width: 100%;
}
}
#coupon-content, #course-content {
padding: 20px;
header {
margin: 0;
......@@ -1046,4 +1075,89 @@ input[name="subject"] {
}
}
}
}
.profile-distribution-widget {
margin-bottom: $baseline * 2;
.display-text {}
.display-graph .graph-placeholder {
width: 750px;
height: 250px;
}
.display-table {
.slickgrid {
height: 250px;
}
}
}
.grade-distributions-widget {
margin-bottom: $baseline * 2;
.last-updated {
line-height: 2.2em;
@include font-size(12);
}
.display-graph .graph-placeholder {
width: 750px;
height: 200px;
}
.display-text {
line-height: 2em;
}
}
input[name="subject"] {
width:600px;
}
.enrollment-wrapper {
margin-bottom: $baseline * 2;
.count {
color: green;
font-weight: bold;
}
}
.ecommerce-wrapper{
h2{
height: 26px;
line-height: 26px;
padding-left: 25px;
span{
float: right;
font-size: 16px;
font-weight: bold;
span{
background: #ddd;
padding: 2px 9px;
border-radius: 2px;
float: none;
font-weight: 400;
}
}
}
span.tip{
padding: 10px 15px;
display: block;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
background: #f8f4ec;
color: #3c3c3c;
line-height: 30px;
.add{
@include button(simple, $blue);
@extend .button-reset;
font-size: em(13);
float: right;
}
}
}
......@@ -63,10 +63,6 @@
height: 35px;
border-bottom: 1px solid #BEBEBE;
th:nth-child(5),th:first-child{
text-align: center;
width: 120px;
}
th {
text-align: left;
border-bottom: 1px solid $border-color-1;
......@@ -75,17 +71,19 @@
width: 100px;
}
&.u-pr {
width: 100px;
width: 70px;
}
&.prc {
width: 150px;
}
&.cur {
width: 100px;
text-align: center;
}
&.dsc{
width: 640px;
padding-right: 50px;
text-align: left;
}
}
}
......@@ -96,22 +94,25 @@
position: relative;
line-height: normal;
span.old-price{
left: -75px;
position: relative;
text-decoration: line-through;
color: red;
font-size: 12px;
top: -1px;
margin-left: 3px;
}
}
td:nth-child(5),td:first-child{
td:nth-child(3){
text-align: center;
}
td:last-child{
width: 50px;
text-align: center;
}
td:nth-child(2){
td:nth-child(1){
line-height: 22px;
padding-right: 50px;
text-align: left;
padding-right: 20px;
}
}
......
......@@ -302,7 +302,8 @@
<% show_email_settings = (course.id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(course.id) %>
<% show_refund_option = (course.id in show_refund_option_for) %>
<%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option" />
<% is_paid_course = (course.id in enrolled_courses_either_paid) %>
<%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course" />
% endfor
</ul>
......
<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option" />
<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course" />
<%! from django.utils.translation import ugettext as _ %>
<%!
......@@ -126,7 +126,19 @@
% endif
% endif
% if enrollment.mode != "verified":
% if is_paid_course and show_refund_option:
## Translators: The course's name will be added to the end of this sentence.
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id.to_deprecated_string()}" data-course-number="${course.number}" onclick="document.getElementById('track-info').innerHTML='${_("Are you sure you want to unregister from the purchased course")}';
document.getElementById('refund-info').innerHTML=gettext('You will be refunded the amount you paid.')">
${_('Unregister')}
</a>
% elif is_paid_course and not show_refund_option:
## Translators: The course's name will be added to the end of this sentence.
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id.to_deprecated_string()}" data-course-number="${course.number}" onclick="document.getElementById('track-info').innerHTML='${_("Are you sure you want to unregister from the purchased course")}';
document.getElementById('refund-info').innerHTML=gettext('You will not be refunded the amount you paid.')">
${_('Unregister')}
</a>
% elif enrollment.mode != "verified":
## Translators: The course's name will be added to the end of this sentence.
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id.to_deprecated_string()}" data-course-number="${course.number}" onclick="document.getElementById('track-info').innerHTML='${_("Are you sure you want to unregister from")}'; document.getElementById('refund-info').innerHTML=''">
${_('Unregister')}
......
<%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %>
<%page args="section_data"/>
<section id="set-course-mode-price-modal" class="modal" role="dialog" tabindex="-1" aria-label="${_('Set Course Mode Price')}">
<div class="inner-wrapper">
<button class="close-modal">
<i class="icon-remove"></i>
<span class="sr">
## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen)
${_('Close')}
</span>
</button>
<div id="course-content">
<header>
<h2>${_("Set Course Mode Price")}</h2>
</header>
<div class="instructions">
<p>
${_("Please enter Course Mode detail below")}</p>
</div>
<form id="set_price_form" action="${section_data['set_course_mode_url']}" method="post" data-remote="true">
<div id="course_form_error" class="modal-form-error"></div>
<fieldset class="group group-form group-form-requiredinformation">
<legend class="is-hidden">${_("Required Information")}</legend>
<ol class="list-input">
<li class="field required text" id="set-course-mode-modal-field-price">
<label for="mode_price" class="required">${_("Course Price")}</label>
<input class="field" id="mode_price" type="text" name="course_price" placeholder="${section_data['course_price']}" aria-required="true">
</li>
<li class="field required text" id="set-course-mode-modal-field-currency">
<label for="course_mode_currency" class="required text">${_("Currency")}</label>
<select class="field required" id="course_mode_currency" name="currency">
<option value="usd">USD</option>
</select>
</li>
</ol>
</fieldset>
<div class="submit">
<input name="submit" type="submit" id="set_course_button" value="${_('Set Price')}"/>
</div>
</form>
</div>
</div>
</section>
......@@ -13,17 +13,15 @@
<table class="cart-table">
<thead>
<tr class="cart-headings">
<th class="qty">${_("Quantity")}</th>
<th class="dsc">${_("Description")}</th>
<th class="u-pr">${_("Unit Price")}</th>
<th class="prc">${_("Price")}</th>
<th class="u-pr">${_("Price")}</th>
<th class="cur">${_("Currency")}</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
% for item in shoppingcart_items:
<tr class="cart-items">
<td>${item.qty}</td>
<td>${item.line_desc}</td>
<td>
${"{0:0.2f}".format(item.unit_cost)}
......@@ -31,14 +29,12 @@
<span class="old-price"> ${"{0:0.2f}".format(item.list_price)}</span>
% endif
</td>
<td>${"{0:0.2f}".format(item.line_cost)}</td>
<td>${item.currency.upper()}</td>
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td>
</tr>
% endfor
<tr class="always-gray">
<td colspan="3"></td>
<td colspan="3" valign="middle" class="cart-total" align="right">
<td colspan="4" valign="middle" class="cart-total" align="right">
<b>${_("Total Amount")}: <span> ${"{0:0.2f}".format(amount)} </span> </b>
</td>
</tr>
......@@ -47,11 +43,15 @@
<tfoot>
<tr class="always-white">
<td colspan="2">
<input type="text" placeholder="Enter coupon code here" name="coupon_code" id="couponCode">
<input type="button" value="Use Coupon" id="cart-coupon">
<input type="text" placeholder="Enter code here" name="cart_code" id="code">
<input type="button" value="Apply Code" id="cart-code">
</td>
<td colspan="4" align="right">
${form_html}
% if amount == 0:
<input type="button" value = "Register" id="register" >
% else:
${form_html}
%endif
</td>
</tr>
......@@ -76,14 +76,14 @@
});
});
$('#cart-coupon').click(function(event){
$('#cart-code').click(function(event){
event.preventDefault();
var post_url = "${reverse('shoppingcart.views.use_coupon')}";
var post_url = "${reverse('shoppingcart.views.use_code')}";
$.post(post_url,{
"coupon_code" : $('#couponCode').val(),
"code" : $('#code').val(),
beforeSend: function(xhr, options){
if($('#couponCode').val() == "") {
showErrorMsgs('Must contain a valid coupon code')
if($('#code').val() == "") {
showErrorMsgs('Must enter a valid code')
xhr.abort();
}
}
......@@ -101,6 +101,22 @@
})
});
$('#register').click(function(event){
event.preventDefault();
var post_url = "${reverse('shoppingcart.views.register_courses')}";
$.post(post_url)
.success(function(data) {
window.location.href = "${reverse('dashboard')}";
})
.error(function(data,status) {
if(status=="parsererror"){
location.reload(true);
}else{
showErrorMsgs(data.responseText)
}
})
});
$('#back_input').click(function(){
history.back();
});
......@@ -110,5 +126,4 @@
$("#cart-error").html(msg);
}
});
</script>
</script>
\ No newline at end of file
......@@ -86,16 +86,18 @@
${_("Note: items with strikethough like <del>this</del> have been refunded.")}
</p>
% endif
<h2>${_("Billed To:")}</h2>
<p>
${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}<br />
${order.bill_to_first} ${order.bill_to_last}<br />
${order.bill_to_street1}<br />
${order.bill_to_street2}<br />
${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}<br />
${order.bill_to_country.upper()}<br />
</p>
% if order.total_cost > 0:
<h2>${_("Billed To:")}</h2>
<p>
${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}<br />
${order.bill_to_first} ${order.bill_to_last}<br />
${order.bill_to_street1}<br />
${order.bill_to_street2}<br />
${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}<br />
${order.bill_to_country.upper()}<br />
</p>
% endif
</article>
</div>
</section>
</div>
......
......@@ -280,6 +280,8 @@ if settings.COURSEWARE_ENABLED:
# For the instructor
url(r'^courses/{}/instructor$'.format(settings.COURSE_ID_PATTERN),
'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard"),
url(r'^courses/{}/set_course_mode_price$'.format(settings.COURSE_ID_PATTERN),
'instructor.views.instructor_dashboard.set_course_mode_price', name="set_course_mode_price"),
url(r'^courses/{}/instructor/api/'.format(settings.COURSE_ID_PATTERN),
include('instructor.views.api_urls')),
url(r'^courses/{}/remove_coupon$'.format(settings.COURSE_ID_PATTERN),
......
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