diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index df5741d479..fb22425a71 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -44,7 +44,7 @@ import instructor.views.api from instructor.views.api import _split_input_list, common_exceptions_400 from instructor_task.api_helper import AlreadyRunningError from opaque_keys.edx.locations import SlashSeparatedCourseKey -from shoppingcart.models import Order, PaidCourseRegistration, Coupon +from shoppingcart.models import CourseRegistrationCode, RegistrationCodeRedemption, Order, PaidCourseRegistration, Coupon from course_modes.models import CourseMode from .test_tools import msk_from_problem_urlname, get_extended_due @@ -2301,3 +2301,192 @@ class TestDueDateExtensions(ModuleStoreTestCase, LoginEnrollmentTestCase): u'header': [u'Unit', u'Extended Due Date'], u'title': u'Due date extensions for %s (%s)' % ( self.user1.profile.name, self.user1.username)}) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +@override_settings(REGISTRATION_CODE_LENGTH=8) +class TestCourseRegistrationCodes(ModuleStoreTestCase): + """ + Test data dumps for E-commerce Course Registration Codes. + """ + def setUp(self): + """ + Fixtures. + """ + self.course = CourseFactory.create() + self.instructor = InstructorFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password='test') + + # Active Registration Codes + for i in range(12): + course_registration_code = CourseRegistrationCode( + code='MyCode0{}'.format(i), course_id=self.course.id.to_deprecated_string(), + transaction_group_name='Test Group', created_by=self.instructor + ) + course_registration_code.save() + + for i in range(5): + order = Order(user=self.instructor, status='purchased') + order.save() + + # Spent(used) Registration Codes + for i in range(5): + i += 1 + registration_code_redemption = RegistrationCodeRedemption( + order_id=i, registration_code_id=i, redeemed_by=self.instructor + ) + registration_code_redemption.save() + + def test_generate_course_registration_codes_csv(self): + """ + Test to generate a response of all the generated course registration codes + """ + url = reverse('generate_registration_codes', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + + data = {'course_registration_code_number': 15.0, 'transaction_group_name': 'Test Group'} + + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 17) + + @patch.object(instructor.views.api, 'random_code_generator', Mock(side_effect=['first', 'second', 'third', 'fourth'])) + def test_generate_course_registration_codes_matching_existing_coupon_code(self): + """ + Test the generated course registration code is already in the Coupon Table + """ + url = reverse('generate_registration_codes', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + + coupon = Coupon(code='first', course_id=self.course.id.to_deprecated_string(), created_by=self.instructor) + coupon.save() + data = {'course_registration_code_number': 3, 'transaction_group_name': 'Test Group'} + + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 5) # 1 for headers, 1 for new line at the end and 3 for the actual data + + @patch.object(instructor.views.api, 'random_code_generator', Mock(side_effect=['first', 'first', 'second', 'third'])) + def test_generate_course_registration_codes_integrity_error(self): + """ + Test for the Integrity error against the generated code + """ + url = reverse('generate_registration_codes', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + + data = {'course_registration_code_number': 2, 'transaction_group_name': 'Test Group'} + + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 4) + + def test_spent_course_registration_codes_csv(self): + """ + Test to generate a response of all the spent course registration codes + """ + url = reverse('spent_registration_codes', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + + data = {'spent_transaction_group_name': ''} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 7) + + for i in range(9): + course_registration_code = CourseRegistrationCode( + code='TestCode{}'.format(i), course_id=self.course.id.to_deprecated_string(), + transaction_group_name='Group Alpha', created_by=self.instructor + ) + course_registration_code.save() + + for i in range(9): + order = Order(user=self.instructor, status='purchased') + order.save() + + # Spent(used) Registration Codes + for i in range(9): + i += 13 + registration_code_redemption = RegistrationCodeRedemption( + order_id=i, registration_code_id=i, redeemed_by=self.instructor + ) + registration_code_redemption.save() + + data = {'spent_transaction_group_name': 'Group Alpha'} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 11) + + def test_active_course_registration_codes_csv(self): + """ + Test to generate a response of all the active course registration codes + """ + url = reverse('active_registration_codes', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + + data = {'active_transaction_group_name': ''} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 9) + + for i in range(9): + course_registration_code = CourseRegistrationCode( + code='TestCode{}'.format(i), course_id=self.course.id.to_deprecated_string(), + transaction_group_name='Group Alpha', created_by=self.instructor + ) + course_registration_code.save() + + data = {'active_transaction_group_name': 'Group Alpha'} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 11) + + def test_get_all_course_registration_codes_csv(self): + """ + Test to generate a response of all the course registration codes + """ + url = reverse('get_registration_codes', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + + data = {'download_transaction_group_name': ''} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 14) + + for i in range(9): + course_registration_code = CourseRegistrationCode( + code='TestCode{}'.format(i), course_id=self.course.id.to_deprecated_string(), + transaction_group_name='Group Alpha', created_by=self.instructor + ) + course_registration_code.save() + + data = {'download_transaction_group_name': 'Group Alpha'} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"code","course_id","transaction_group_name","created_by","redeemed_by"')) + self.assertEqual(len(body.split('\n')), 11) diff --git a/lms/djangoapps/instructor/tests/test_ecommerce.py b/lms/djangoapps/instructor/tests/test_ecommerce.py index 34a8f3c0d9..19020b7c3d 100644 --- a/lms/djangoapps/instructor/tests/test_ecommerce.py +++ b/lms/djangoapps/instructor/tests/test_ecommerce.py @@ -11,7 +11,7 @@ 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 @@ -107,6 +107,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 @@ -213,3 +224,15 @@ class TestECommerceDashboardViews(ModuleStoreTestCase): data = {'coupon_id': coupon.id, 'code': '11111', 'discount': '12'} 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) + + 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', + 'discount': '6', 'course_id': coupon.course_id.to_deprecated_string()} + response = self.client.post(update_coupon_url, data=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) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 365c98ddde..99a6e6d4dc 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -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 @@ -561,7 +566,7 @@ def get_purchase_transaction(request, course_id, csv=False): # pylint: disable= 'order_id', ] - student_data = analytics.basic.purchase_transactions(course_id, query_features) + student_data = instructor_analytics.basic.purchase_transactions(course_id, query_features) if not csv: response_payload = { @@ -630,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 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') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 77ebf3d479..0f3b5d0d11 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -58,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'), diff --git a/lms/djangoapps/instructor/views/coupons.py b/lms/djangoapps/instructor/views/coupons.py index 2b0650d31b..b6ee401732 100644 --- a/lms/djangoapps/instructor/views/coupons.py +++ b/lms/djangoapps/instructor/views/coupons.py @@ -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: diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 2590fc373a..baac51455e 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -144,6 +144,10 @@ def _section_e_commerce(course_key, access): '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()}), 'coupons': coupons, 'total_amount': total_amount, } diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 2254cdfc3a..ad29473355 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -6,6 +6,7 @@ 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') @@ -15,6 +16,7 @@ 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): @@ -98,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 diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 3cb70033db..41e50f0ef4 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -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] + ) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index e5b8af013b..7cd2b7d7bd 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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) diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 074dcf5092..0f4e91d547 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -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: diff --git a/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee b/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee index 24449f771a..8d71b44085 100644 --- a/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee +++ b/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee @@ -1,17 +1,29 @@ ### -E-Commerce Download Section +E-Commerce Section ### -# Ecommerce Purchase Download 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']'") + @$transaction_group_name = @$section.find("input[name='transaction_group_name']'") + @$download_transaction_group_name = @$section.find("input[name='transaction_group_name']'") + @$active_transaction_group_name = @$section.find("input[name='transaction_group_name']'") + @$spent_transaction_group_name = @$section.find('input[name="course_registration_code_number"]') + @$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 @@ -20,17 +32,72 @@ class ECommerce 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() - 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 + ECommerce: ECommerce \ No newline at end of file diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 3a0efe76f0..d76ab6fdbb 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -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){ diff --git a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html index dc2b59ec32..a97a199c03 100644 --- a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html +++ b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html @@ -4,8 +4,46 @@ <%include file="edit_coupon_modal.html" args="section_data=section_data" />
Enter the transaction group name and number of registration codes that you want to generate. Click to generate a CSV :
++
+ -%if section_data['access']['finance_admin'] is True: +Click to generate a CSV file of all Course Registrations Codes:
++
+ + +Click to generate a CSV file of all Active Course Registrations Codes:
++
+ + +Click to generate a CSV file of all Spent Course Registrations Codes:
++
+ +