diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 14be87b3000a12f6499a3efef8177d534b9a4b32..94a81590e4fd813c35ce37ff038a80f4dcfb5786 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -446,13 +446,17 @@ def is_course_blocked(request, redeemed_registration_codes, course_key): """Checking either registration is blocked or not .""" blocked = False for redeemed_registration in redeemed_registration_codes: - if not getattr(redeemed_registration.invoice, 'is_valid'): - blocked = True - # disabling email notifications for unpaid registration courses - Optout.objects.get_or_create(user=request.user, course_id=course_key) - log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(request.user.username, request.user.email, course_key)) - track.views.server_track(request, "change-email1-settings", {"receive_emails": "no", "course": course_key.to_deprecated_string()}, page='dashboard') - break + # registration codes may be generated via Bulk Purchase Scenario + # we have to check only for the invoice generated registration codes + # that their invoice is valid or not + if redeemed_registration.invoice: + if not getattr(redeemed_registration.invoice, 'is_valid'): + blocked = True + # disabling email notifications for unpaid registration courses + Optout.objects.get_or_create(user=request.user, course_id=course_key) + log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(request.user.username, request.user.email, course_key)) + track.views.server_track(request, "change-email1-settings", {"receive_emails": "no", "course": course_key.to_deprecated_string()}, page='dashboard') + break return blocked diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index f92326690a87afca5e91f5ca4ec88d15923e3915..520c2f7db4af99b1f30b896c6650879097ef1299 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -39,7 +39,7 @@ from course_modes.models import CourseMode from open_ended_grading import open_ended_notifications from student.models import UserTestGroup, CourseEnrollment -from student.views import single_course_reverification_info +from student.views import single_course_reverification_info, is_course_blocked from util.cache import cache, cache_if_anonymous from xblock.fragment import Fragment from xmodule.modulestore.django import modulestore @@ -277,12 +277,22 @@ def index(request, course_id, chapter=None, section=None, user = User.objects.prefetch_related("groups").get(id=request.user.id) - # Redirecting to dashboard if the course is blocked due to un payment. - redeemed_registration_codes = CourseRegistrationCode.objects.filter(course_id=course_key, registrationcoderedemption__redeemed_by=request.user) - for redeemed_registration in redeemed_registration_codes: - if not getattr(redeemed_registration.invoice, 'is_valid'): - log.warning(u'User %s cannot access the course %s because payment has not yet been received', user, course_key.to_deprecated_string()) - return redirect(reverse('dashboard')) + redeemed_registration_codes = CourseRegistrationCode.objects.filter( + course_id=course_key, + registrationcoderedemption__redeemed_by=request.user + ) + + # Redirect to dashboard if the course is blocked due to non-payment. + if is_course_blocked(request, redeemed_registration_codes, course_key): + # registration codes may be generated via Bulk Purchase Scenario + # we have to check only for the invoice generated registration codes + # that their invoice is valid or not + log.warning( + u'User %s cannot access the course %s because payment has not yet been received', + user, + course_key.to_deprecated_string() + ) + return redirect(reverse('dashboard')) request.user = user # keep just one instance of User with modulestore().bulk_operations(course_key): @@ -703,7 +713,8 @@ def course_about(request, course_id): settings.PAID_COURSE_REGISTRATION_CURRENCY[0]) if request.user.is_authenticated(): cart = shoppingcart.models.Order.get_cart_for_user(request.user) - in_cart = shoppingcart.models.PaidCourseRegistration.contained_in_order(cart, course_key) + in_cart = shoppingcart.models.PaidCourseRegistration.contained_in_order(cart, course_key) or \ + shoppingcart.models.CourseRegCodeItem.contained_in_order(cart, course_key) reg_then_add_to_cart_link = "{reg_url}?course_id={course_id}&enrollment_action=add_to_cart".format( reg_url=reverse('register_user'), course_id=course.id.to_deprecated_string()) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 480622672b45ebce33d521cb9b5536a2726baabf..94c980fa5ae0a58b0b38021a96a581077b754277 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -1448,6 +1448,21 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa response = self.client.get(url + '/csv', {}) self.assertEqual(response['Content-Type'], 'text/csv') + def test_get_sale_order_records_features_csv(self): + """ + Test that the response from get_sale_order_records is in csv format. + """ + self.cart.order_type = 'business' + self.cart.save() + self.cart.add_billing_details(company_name='Test Company', company_contact_name='Test', + company_contact_email='test@123', recipient_name='R1', + recipient_email='', customer_reference_number='PO#23') + PaidCourseRegistration.add_to_order(self.cart, self.course.id) + self.cart.purchase() + sale_order_url = reverse('get_sale_order_records', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(sale_order_url) + self.assertEqual(response['Content-Type'], 'text/csv') + def test_get_sale_records_features_csv(self): """ Test that the response from get_sale_records is in csv format. diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index c395823a149f4693f77e8b64faa98c5adc9aee53..7b92a1351648455ccc8ed80d7302bd6b5f3cc64a 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -588,7 +588,47 @@ def get_sale_records(request, course_id, csv=False): # pylint: disable=W0613, W return JsonResponse(response_payload) else: header, datarows = instructor_analytics.csvs.format_dictlist(sale_data, query_features) - return instructor_analytics.csvs.create_csv_response("e-commerce_sale_records.csv", header, datarows) + return instructor_analytics.csvs.create_csv_response("e-commerce_sale_invoice_records.csv", header, datarows) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def get_sale_order_records(request, course_id): # pylint: disable=W0613, W0621 + """ + return the summary of all sales records for a particular course + """ + course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + query_features = [ + ('id', 'Order Id'), + ('company_name', 'Company Name'), + ('company_contact_name', 'Company Contact Name'), + ('company_contact_email', 'Company Contact Email'), + ('total_amount', 'Total Amount'), + ('total_codes', 'Total Codes'), + ('total_used_codes', 'Total Used Codes'), + ('logged_in_username', 'Login Username'), + ('logged_in_email', 'Login User Email'), + ('purchase_time', 'Date of Sale'), + ('customer_reference_number', 'Customer Reference Number'), + ('recipient_name', 'Recipient Name'), + ('recipient_email', 'Recipient Email'), + ('bill_to_street1', 'Street 1'), + ('bill_to_street2', 'Street 2'), + ('bill_to_city', 'City'), + ('bill_to_state', 'State'), + ('bill_to_postalcode', 'Postal Code'), + ('bill_to_country', 'Country'), + ('order_type', 'Order Type'), + ('codes', 'Registration Codes'), + ('course_id', 'Course Id') + ] + + db_columns = [x[0] for x in query_features] + csv_columns = [x[1] for x in query_features] + sale_data = instructor_analytics.basic.sale_order_record_features(course_id, db_columns) + header, datarows = instructor_analytics.csvs.format_dictlist(sale_data, db_columns) # pylint: disable=W0612 + return instructor_analytics.csvs.create_csv_response("e-commerce_sale_order_records.csv", csv_columns, datarows) @require_level('staff') @@ -766,7 +806,7 @@ def get_coupon_codes(request, course_id): # pylint: disable=W0613 return instructor_analytics.csvs.create_csv_response('Coupons.csv', header, data_rows) -def save_registration_codes(request, course_id, generated_codes_list, invoice): +def save_registration_code(user, course_id, invoice=None, order=None): """ recursive function that generate a new code every time and saves in the Course Registration Table if validation check passes @@ -776,16 +816,16 @@ def save_registration_codes(request, course_id, generated_codes_list, invoice): # 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, invoice) + return save_registration_code(user, course_id, invoice, order) course_registration = CourseRegistrationCode( - code=code, course_id=course_id.to_deprecated_string(), created_by=request.user, invoice=invoice + code=code, course_id=course_id.to_deprecated_string(), created_by=user, invoice=invoice, order=order ) try: course_registration.save() - generated_codes_list.append(course_registration) + return course_registration except IntegrityError: - return save_registration_codes(request, course_id, generated_codes_list, invoice) + return save_registration_code(user, course_id, invoice, order) def registration_codes_csv(file_name, codes_list, csv_type=None): @@ -851,7 +891,6 @@ 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 = [] invoice_copy = False # covert the course registration code number into integer @@ -888,8 +927,10 @@ def generate_registration_codes(request, course_id): address_line_3=address_line_3, city=city, state=state, zip=zip_code, country=country, internal_reference=internal_reference, customer_reference_number=customer_reference_number ) + registration_codes = [] for _ in range(course_code_number): # pylint: disable=W0621 - save_registration_codes(request, course_id, course_registration_codes, sale_invoice) + generated_registration_code = save_registration_code(request.user, course_id, sale_invoice, order=None) + registration_codes.append(generated_registration_code) site_name = microsite.get_value('SITE_NAME', 'localhost') course = get_course_by_id(course_id, depth=None) @@ -916,7 +957,7 @@ def generate_registration_codes(request, course_id): 'discount': discount, 'sale_price': sale_price, 'quantity': quantity, - 'registration_codes': course_registration_codes, + 'registration_codes': registration_codes, 'course_url': course_url, 'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME), 'dashboard_url': dashboard_url, @@ -934,7 +975,7 @@ def generate_registration_codes(request, course_id): #send_mail(subject, message, from_address, recipient_list, fail_silently=False) csv_file = StringIO.StringIO() csv_writer = csv.writer(csv_file) - for registration_code in course_registration_codes: + for registration_code in registration_codes: csv_writer.writerow([registration_code.code]) # send a unique email for each recipient, don't put all email addresses in a single email @@ -948,7 +989,7 @@ def generate_registration_codes(request, course_id): email.attach(u'Invoice.txt', invoice_attachment, 'text/plain') email.send() - return registration_codes_csv("Registration_Codes.csv", course_registration_codes) + return registration_codes_csv("Registration_Codes.csv", registration_codes) @ensure_csrf_cookie diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 3bf9ba651f5890fd5a841ab5e96503253772e22b..4968f1b1045a85eccb753530b89695b4d915c0ee 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -23,6 +23,8 @@ urlpatterns = patterns('', # nopep8 'instructor.views.api.get_user_invoice_preference', name="get_user_invoice_preference"), url(r'^get_sale_records(?P<csv>/csv)?$', 'instructor.views.api.get_sale_records', name="get_sale_records"), + url(r'^get_sale_order_records$', + 'instructor.views.api.get_sale_order_records', name="get_sale_order_records"), url(r'^sale_validation_url$', 'instructor.views.api.sale_validation', name="sale_validation"), url(r'^get_anon_ids$', diff --git a/lms/djangoapps/instructor/views/coupons.py b/lms/djangoapps/instructor/views/coupons.py index 48f9828863076a4ae15e49b31d3e9fe29f2f14a1..c4ec58d9d246e8874589e6cae1504f17c32a9f62 100644 --- a/lms/djangoapps/instructor/views/coupons.py +++ b/lms/djangoapps/instructor/views/coupons.py @@ -62,27 +62,36 @@ def add_coupon(request, course_id): # pylint: disable=W0613 # 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) - ) + return JsonResponse( + {'message': _("The code ({code}) that you have tried to define is already in use as a registration code").format(code=code)}, + status=400) # status code 400: Bad Request description = request.POST.get('description') course_id = request.POST.get('course_id') try: discount = int(request.POST.get('discount')) except ValueError: - return HttpResponseNotFound(_("Please Enter the Integer Value for Coupon Discount")) + return JsonResponse({ + 'message': _("Please Enter the Integer Value for Coupon Discount") + }, status=400) # status code 400: Bad Request + if discount > 100 or discount < 0: - return HttpResponseNotFound(_("Please Enter the Coupon Discount Value Less than or Equal to 100")) + return JsonResponse({ + 'message': _("Please Enter the Coupon Discount Value Less than or Equal to 100") + }, status=400) # status code 400: Bad Request coupon = Coupon( code=code, description=description, course_id=course_id, percentage_discount=discount, created_by_id=request.user.id ) coupon.save() - return HttpResponse(_("coupon with the coupon code ({code}) added successfully").format(code=code)) + return JsonResponse( + {'message': _("coupon with the coupon code ({code}) added successfully").format(code=code)} + ) if coupon: - return HttpResponseNotFound(_("coupon with the coupon code ({code}) already exists for this course").format(code=code)) + return JsonResponse( + {'message': _("coupon with the coupon code ({code}) already exists for this course").format(code=code)}, + status=400) # status code 400: Bad Request @require_POST @@ -93,17 +102,21 @@ def update_coupon(request, course_id): # pylint: disable=W0613 """ coupon_id = request.POST.get('coupon_id', None) if not coupon_id: - return HttpResponseNotFound(_("coupon id not found")) + return JsonResponse({'message': _("coupon id not found")}, status=400) # status code 400: Bad Request try: coupon = Coupon.objects.get(pk=coupon_id) except ObjectDoesNotExist: - return HttpResponseNotFound(_("coupon with the coupon id ({coupon_id}) DoesNotExist").format(coupon_id=coupon_id)) + return JsonResponse( + {'message': _("coupon with the coupon id ({coupon_id}) DoesNotExist").format(coupon_id=coupon_id)}, + status=400) # status code 400: Bad Request description = request.POST.get('description') coupon.description = description coupon.save() - return HttpResponse(_("coupon with the coupon id ({coupon_id}) updated Successfully").format(coupon_id=coupon_id)) + return JsonResponse( + {'message': _("coupon with the coupon id ({coupon_id}) updated Successfully").format(coupon_id=coupon_id)} + ) @require_POST diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index af1959f20931806556eab9f51d38d63e83c20948..12e01a32c379ec0c3c756f862f2b13f62d51a97b 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -17,6 +17,7 @@ from django.core.urlresolvers import reverse from django.utils.html import escape from django.http import Http404, HttpResponse, HttpResponseNotFound from django.conf import settings +from util.json_request import JsonResponse from lms.lib.xblock.runtime import quote_slashes from xmodule_modifiers import wrap_xblock @@ -158,6 +159,7 @@ def _section_e_commerce(course, 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()}), 'get_sale_records_url': reverse('get_sale_records', kwargs={'course_id': course_key.to_deprecated_string()}), + 'get_sale_order_records_url': reverse('get_sale_order_records', 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()}), @@ -183,15 +185,19 @@ def set_course_mode_price(request, course_id): try: course_price = int(request.POST['course_price']) except ValueError: - return HttpResponseNotFound(_("Please Enter the numeric value for the course price")) + return JsonResponse( + {'message': _("Please Enter the numeric value for the course price")}, + status=400) # status code 400: Bad Request + 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') - ) + return JsonResponse( + {'message': _("CourseMode with the mode slug({mode_slug}) DoesNotExist").format(mode_slug='honor')}, + status=400) # status code 400: Bad Request + 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'), @@ -201,7 +207,7 @@ def set_course_mode_price(request, course_id): min_price=course_price, currency=currency ) - return HttpResponse(_("CourseMode price updated successfully")) + return JsonResponse({'message': _("CourseMode price updated successfully")}) def _section_course_info(course, access): diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index eecede860ae5b977fc973ec35892637c8f2cffe4..f26ccde17a0236512b4a5512dfd39396d5fe1355 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -3,7 +3,10 @@ Student and course analytics. Serve miscellaneous course and student data """ -from shoppingcart.models import PaidCourseRegistration, CouponRedemption, Invoice, RegistrationCodeRedemption +from shoppingcart.models import ( + PaidCourseRegistration, CouponRedemption, Invoice, CourseRegCodeItem, + OrderTypes, RegistrationCodeRedemption, CourseRegistrationCode +) from django.contrib.auth.models import User import xmodule.graders as xmgraders from django.core.exceptions import ObjectDoesNotExist @@ -18,11 +21,77 @@ ORDER_FEATURES = ('purchase_time',) SALE_FEATURES = ('total_amount', 'company_name', 'company_contact_name', 'company_contact_email', 'recipient_name', 'recipient_email', 'customer_reference_number', 'internal_reference') +SALE_ORDER_FEATURES = ('id', 'company_name', 'company_contact_name', 'company_contact_email', 'purchase_time', + 'customer_reference_number', 'recipient_name', 'recipient_email', 'bill_to_street1', + 'bill_to_street2', 'bill_to_city', 'bill_to_state', 'bill_to_postalcode', + 'bill_to_country', 'order_type',) + AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at') COUPON_FEATURES = ('course_id', 'percentage_discount', 'description') +def sale_order_record_features(course_id, features): + """ + Return list of sale orders features as dictionaries. + + sales_records(course_id, ['company_name, total_codes', total_amount]) + would return [ + {'company_name': 'group_A', 'total_codes': '1', total_amount:'total_amount1 in decimal'.} + {'company_name': 'group_B', 'total_codes': '2', total_amount:'total_amount2 in decimal'.} + {'company_name': 'group_C', 'total_codes': '3', total_amount:'total_amount3 in decimal'.} + ] + """ + purchased_courses = PaidCourseRegistration.objects.filter(course_id=course_id, status='purchased').order_by('order') + purchased_course_reg_codes = CourseRegCodeItem.objects.filter(course_id=course_id, status='purchased').order_by('order') + + def sale_order_info(purchased_course, features): + """ + convert purchase transactions to dictionary + """ + + sale_order_features = [x for x in SALE_ORDER_FEATURES if x in features] + course_reg_features = [x for x in COURSE_REGISTRATION_FEATURES if x in features] + + # Extracting order information + sale_order_dict = dict((feature, getattr(purchased_course.order, feature)) + for feature in sale_order_features) + + quantity = int(getattr(purchased_course, 'qty')) + unit_cost = float(getattr(purchased_course, 'unit_cost')) + sale_order_dict.update({"total_amount": quantity * unit_cost}) + + sale_order_dict.update({"logged_in_username": purchased_course.order.user.username}) + sale_order_dict.update({"logged_in_email": purchased_course.order.user.email}) + + sale_order_dict.update({"total_codes": 'N/A'}) + sale_order_dict.update({'total_used_codes': 'N/A'}) + + if getattr(purchased_course.order, 'order_type') == OrderTypes.BUSINESS: + registration_codes = CourseRegistrationCode.objects.filter(order=purchased_course.order, course_id=course_id) + sale_order_dict.update({"total_codes": registration_codes.count()}) + sale_order_dict.update({'total_used_codes': RegistrationCodeRedemption.objects.filter(registration_code__in=registration_codes).count()}) + + codes = list() + for reg_code in registration_codes: + codes.append(reg_code.code) + + # Extracting registration code information + obj_course_reg_code = registration_codes.all()[:1].get() + course_reg_dict = dict((feature, getattr(obj_course_reg_code, feature)) + for feature in course_reg_features) + + course_reg_dict['course_id'] = course_id.to_deprecated_string() + course_reg_dict.update({'codes': ", ".join(codes)}) + sale_order_dict.update(dict(course_reg_dict.items())) + + return sale_order_dict + + csv_data = [sale_order_info(purchased_course, features) for purchased_course in purchased_courses] + csv_data.extend([sale_order_info(purchased_course_reg_code, features) for purchased_course_reg_code in purchased_course_reg_codes]) + return csv_data + + def sale_record_features(course_id, features): """ Return list of sales features as dictionaries. @@ -73,7 +142,7 @@ def purchase_transactions(course_id, features): """ Return list of purchased transactions features as dictionaries. - purchase_transactions(course_id, ['username, email', unit_cost]) + purchase_transactions(course_id, ['username, email','created_by', unit_cost]) would return [ {'username': 'username1', 'email': 'email1', unit_cost:'cost1 in decimal'.} {'username': 'username2', 'email': 'email2', unit_cost:'cost2 in decimal'.} diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 788c55fb12c7e770da2848bc682f84a56fb55b81..ad735de250ad02a46cf062b6db67df928750b0e0 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -7,11 +7,11 @@ from student.models import CourseEnrollment from django.core.urlresolvers import reverse from student.tests.factories import UserFactory from opaque_keys.edx.locations import SlashSeparatedCourseKey -from shoppingcart.models import CourseRegistrationCode, RegistrationCodeRedemption, Order, Invoice, Coupon +from shoppingcart.models import CourseRegistrationCode, RegistrationCodeRedemption, Order, Invoice, Coupon, CourseRegCodeItem from instructor_analytics.basic import ( - sale_record_features, enrolled_students_features, course_registration_features, coupon_codes_features, - AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES + sale_record_features, sale_order_record_features, enrolled_students_features, course_registration_features, + coupon_codes_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES ) from course_groups.tests.helpers import CohortFactory from course_groups.models import CourseUserGroup @@ -137,6 +137,57 @@ class TestCourseSaleRecordsAnalyticsBasic(ModuleStoreTestCase): self.assertEqual(sale_record['total_used_codes'], 0) self.assertEqual(sale_record['total_codes'], 5) + def test_sale_order_features(self): + """ + Test Order Sales Report CSV + """ + query_features = [ + ('id', 'Order Id'), + ('company_name', 'Company Name'), + ('company_contact_name', 'Company Contact Name'), + ('company_contact_email', 'Company Contact Email'), + ('total_amount', 'Total Amount'), + ('total_codes', 'Total Codes'), + ('total_used_codes', 'Total Used Codes'), + ('logged_in_username', 'Login Username'), + ('logged_in_email', 'Login User Email'), + ('purchase_time', 'Date of Sale'), + ('customer_reference_number', 'Customer Reference Number'), + ('recipient_name', 'Recipient Name'), + ('recipient_email', 'Recipient Email'), + ('bill_to_street1', 'Street 1'), + ('bill_to_street2', 'Street 2'), + ('bill_to_city', 'City'), + ('bill_to_state', 'State'), + ('bill_to_postalcode', 'Postal Code'), + ('bill_to_country', 'Country'), + ('order_type', 'Order Type'), + ('codes', 'Registration Codes'), + ('course_id', 'Course Id') + ] + + order = Order.get_cart_for_user(self.instructor) + order.order_type = 'business' + order.save() + order.add_billing_details(company_name='Test Company', company_contact_name='Test', + company_contact_email='test@123', recipient_name='R1', + recipient_email='', customer_reference_number='PO#23') + CourseRegCodeItem.add_to_order(order, self.course.id, 4) + order.purchase() + + db_columns = [x[0] for x in query_features] + sale_order_records_list = sale_order_record_features(self.course.id, db_columns) + + for sale_order_record in sale_order_records_list: + self.assertEqual(sale_order_record['recipient_email'], order.recipient_email) + self.assertEqual(sale_order_record['recipient_name'], order.recipient_name) + self.assertEqual(sale_order_record['company_name'], order.company_name) + self.assertEqual(sale_order_record['company_contact_name'], order.company_contact_name) + self.assertEqual(sale_order_record['company_contact_email'], order.company_contact_email) + self.assertEqual(sale_order_record['customer_reference_number'], order.customer_reference_number) + self.assertEqual(sale_order_record['total_used_codes'], order.registrationcoderedemption_set.all().count()) + self.assertEqual(sale_order_record['total_codes'], len(CourseRegistrationCode.objects.filter(order=order))) + class TestCourseRegistrationCodeAnalyticsBasic(ModuleStoreTestCase): """ Test basic course registration codes analytics functions. """ diff --git a/lms/djangoapps/shoppingcart/context_processor.py b/lms/djangoapps/shoppingcart/context_processor.py index 540ab6a2f32321a7aea6b1411439a04870169d1c..df667569d0859b9127aa153917429b6f41ab42f2 100644 --- a/lms/djangoapps/shoppingcart/context_processor.py +++ b/lms/djangoapps/shoppingcart/context_processor.py @@ -21,6 +21,6 @@ def user_has_cart_context_processor(request): settings.FEATURES.get('ENABLE_SHOPPING_CART') and # settings enable shopping cart and shoppingcart.models.Order.user_cart_has_items( request.user, - shoppingcart.models.PaidCourseRegistration + [shoppingcart.models.PaidCourseRegistration, shoppingcart.models.CourseRegCodeItem] ) # user's cart has PaidCourseRegistrations )} diff --git a/lms/djangoapps/shoppingcart/migrations/0020_auto__add_courseregcodeitem__add_courseregcodeitemannotation__add_fiel.py b/lms/djangoapps/shoppingcart/migrations/0020_auto__add_courseregcodeitem__add_courseregcodeitemannotation__add_fiel.py new file mode 100644 index 0000000000000000000000000000000000000000..ce99fdb642e5f634ae95f6a84f2ec23ef14bb00a --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0020_auto__add_courseregcodeitem__add_courseregcodeitemannotation__add_fiel.py @@ -0,0 +1,284 @@ +# -*- 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 'CourseRegCodeItem' + db.create_table('shoppingcart_courseregcodeitem', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=128, db_index=True)), + ('mode', self.gf('django.db.models.fields.SlugField')(default='honor', max_length=50)), + )) + db.send_create_signal('shoppingcart', ['CourseRegCodeItem']) + + # Adding model 'CourseRegCodeItemAnnotation' + db.create_table('shoppingcart_courseregcodeitemannotation', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(unique=True, max_length=128, db_index=True)), + ('annotation', self.gf('django.db.models.fields.TextField')(null=True)), + )) + db.send_create_signal('shoppingcart', ['CourseRegCodeItemAnnotation']) + + # Adding field 'Order.company_name' + db.add_column('shoppingcart_order', 'company_name', + self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.company_contact_name' + db.add_column('shoppingcart_order', 'company_contact_name', + self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.company_contact_email' + db.add_column('shoppingcart_order', 'company_contact_email', + self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.recipient_name' + db.add_column('shoppingcart_order', 'recipient_name', + self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.recipient_email' + db.add_column('shoppingcart_order', 'recipient_email', + self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.customer_reference_number' + db.add_column('shoppingcart_order', 'customer_reference_number', + self.gf('django.db.models.fields.CharField')(max_length=63, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.order_type' + db.add_column('shoppingcart_order', 'order_type', + self.gf('django.db.models.fields.CharField')(default='personal', max_length=32), + keep_default=False) + + + def backwards(self, orm): + # Deleting model 'CourseRegCodeItem' + db.delete_table('shoppingcart_courseregcodeitem') + + # Deleting model 'CourseRegCodeItemAnnotation' + db.delete_table('shoppingcart_courseregcodeitemannotation') + + # Deleting field 'Order.company_name' + db.delete_column('shoppingcart_order', 'company_name') + + # Deleting field 'Order.company_contact_name' + db.delete_column('shoppingcart_order', 'company_contact_name') + + # Deleting field 'Order.company_contact_email' + db.delete_column('shoppingcart_order', 'company_contact_email') + + # Deleting field 'Order.recipient_name' + db.delete_column('shoppingcart_order', 'recipient_name') + + # Deleting field 'Order.recipient_email' + db.delete_column('shoppingcart_order', 'recipient_email') + + # Deleting field 'Order.customer_reference_number' + db.delete_column('shoppingcart_order', 'customer_reference_number') + + # Deleting field 'Order.order_type' + db.delete_column('shoppingcart_order', 'order_type') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.coupon': { + 'Meta': {'object_name': 'Coupon'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 10, 16, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'percentage_discount': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'shoppingcart.couponredemption': { + 'Meta': {'object_name': 'CouponRedemption'}, + 'coupon': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Coupon']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.courseregcodeitem': { + 'Meta': {'object_name': 'CourseRegCodeItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.courseregcodeitemannotation': { + 'Meta': {'object_name': 'CourseRegCodeItemAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.courseregistrationcode': { + 'Meta': {'object_name': 'CourseRegistrationCode'}, + 'code': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 10, 16, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user'", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Invoice']", 'null': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'purchase_order'", 'null': 'True', 'to': "orm['shoppingcart.Order']"}) + }, + 'shoppingcart.donation': { + 'Meta': {'object_name': 'Donation', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'donation_type': ('django.db.models.fields.CharField', [], {'default': "'general'", 'max_length': '32'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.donationconfiguration': { + 'Meta': {'object_name': 'DonationConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.invoice': { + 'Meta': {'object_name': 'Invoice'}, + 'address_line_1': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'address_line_2': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'address_line_3': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'internal_reference': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'is_valid': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'state': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'total_amount': ('django.db.models.fields.FloatField', [], {}), + 'zip': ('django.db.models.fields.CharField', [], {'max_length': '15', 'null': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order_type': ('django.db.models.fields.CharField', [], {'default': "'personal'", 'max_length': '32'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'refunded_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'list_price': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '30', 'decimal_places': '2'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'service_fee': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32', 'db_index': 'True'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.registrationcoderedemption': { + 'Meta': {'object_name': 'RegistrationCodeRedemption'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']", 'null': 'True'}), + 'redeemed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 10, 16, 0, 0)', 'null': 'True'}), + 'redeemed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'registration_code': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.CourseRegistrationCode']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 6bcab0d280ffe708762cc3b2f65a0a7e677a5208..2b27dcc252349408d2817c8688251ec44e5bed22 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -6,7 +6,9 @@ from decimal import Decimal import pytz import logging import smtplib - +import StringIO +import csv +from courseware.courses import get_course_by_id from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors from django.dispatch import receiver from django.db import models @@ -19,6 +21,7 @@ from django.db import transaction from django.db.models import Sum from django.core.urlresolvers import reverse from model_utils.managers import InheritanceManager +from django.core.mail.message import EmailMessage from xmodule.modulestore.django import modulestore @@ -62,6 +65,19 @@ ORDER_STATUSES = ( OrderItemSubclassPK = namedtuple('OrderItemSubclassPK', ['cls', 'pk']) # pylint: disable=C0103 +class OrderTypes(object): + """ + This class specify purchase OrderTypes. + """ + PERSONAL = 'personal' + BUSINESS = 'business' + + ORDER_TYPES = ( + (PERSONAL, 'personal'), + (BUSINESS, 'business'), + ) + + class Order(models.Model): """ This is the model for an order. Before purchase, an Order and its related OrderItems are used @@ -88,6 +104,15 @@ class Order(models.Model): # a JSON dump of the CC processor response, for completeness processor_reply_dump = models.TextField(blank=True) + # bulk purchase registration code workflow billing details + company_name = models.CharField(max_length=255, null=True, blank=True) + company_contact_name = models.CharField(max_length=255, null=True, blank=True) + company_contact_email = models.CharField(max_length=255, null=True, blank=True) + recipient_name = models.CharField(max_length=255, null=True, blank=True) + recipient_email = models.CharField(max_length=255, null=True, blank=True) + customer_reference_number = models.CharField(max_length=63, null=True, blank=True) + order_type = models.CharField(max_length=32, default='personal', choices=OrderTypes.ORDER_TYPES) + @classmethod def get_cart_for_user(cls, user): """ @@ -102,7 +127,7 @@ class Order(models.Model): return cart_order @classmethod - def user_cart_has_items(cls, user, item_type=None): + def user_cart_has_items(cls, user, item_types=None): """ Returns true if the user (anonymous user ok) has a cart with items in it. (Which means it should be displayed. @@ -112,7 +137,17 @@ class Order(models.Model): if not user.is_authenticated(): return False cart = cls.get_cart_for_user(user) - return cart.has_items(item_type) + + if not item_types: + # check to see if the cart has at least some item in it + return cart.has_items() + else: + # if the caller is explicitly asking to check for particular types + for item_type in item_types: + if cart.has_items(item_type): + return True + + return False @property def total_cost(self): @@ -130,17 +165,27 @@ class Order(models.Model): if not item_type: return self.orderitem_set.exists() # pylint: disable=E1101 else: - items = self.orderitem_set.all().select_subclasses() + items = self.orderitem_set.all().select_subclasses() # pylint: disable=E1101 for item in items: if isinstance(item, item_type): return True return False + def reset_cart_items_prices(self): + """ + Reset the items price state in the user cart + """ + for item in self.orderitem_set.all(): # pylint: disable=E1101 + if item.list_price: + item.unit_cost = item.list_price + item.list_price = None + item.save() + def clear(self): """ Clear out all the items in the cart """ - self.orderitem_set.all().delete() + self.orderitem_set.all().delete() # pylint: disable=E1101 @transaction.commit_on_success def start_purchase(self): @@ -158,6 +203,122 @@ class Order(models.Model): for item in OrderItem.objects.filter(order=self).select_subclasses(): item.start_purchase() + def update_order_type(self): + """ + updating order type. This method wil inspect the quantity associated with the OrderItem. + In the application, it is implied that when qty > 1, then the user is to purchase + 'RegistrationCodes' which are randomly generated strings that users can distribute to + others in order for them to enroll in paywalled courses. + + The UI/UX may change in the future to make the switching between PaidCourseRegistration + and CourseRegCodeItems a more explicit UI gesture from the purchaser + """ + cart_items = self.orderitem_set.all() # pylint: disable=E1101 + is_order_type_business = False + for cart_item in cart_items: + if cart_item.qty > 1: + is_order_type_business = True + + items_to_delete = [] + if is_order_type_business: + for cart_item in cart_items: + if hasattr(cart_item, 'paidcourseregistration'): + CourseRegCodeItem.add_to_order(self, cart_item.paidcourseregistration.course_id, cart_item.qty) + items_to_delete.append(cart_item) + else: + for cart_item in cart_items: + if hasattr(cart_item, 'courseregcodeitem'): + PaidCourseRegistration.add_to_order(self, cart_item.courseregcodeitem.course_id) + items_to_delete.append(cart_item) + # CourseRegCodeItem.add_to_order + + for item in items_to_delete: + item.delete() + + self.order_type = OrderTypes.BUSINESS if is_order_type_business else OrderTypes.PERSONAL + self.save() + + def generate_registration_codes_csv(self, orderitems, site_name): + """ + this function generates the csv file + """ + course_info = [] + csv_file = StringIO.StringIO() + csv_writer = csv.writer(csv_file) + csv_writer.writerow(['Course Name', 'Registration Code', 'URL']) + for item in orderitems: + course_id = item.course_id + course = get_course_by_id(getattr(item, 'course_id'), depth=0) + registration_codes = CourseRegistrationCode.objects.filter(course_id=course_id, order=self) + course_info.append((course.display_name, ' (' + course.start_date_text + '-' + course.end_date_text + ')')) + for registration_code in registration_codes: + redemption_url = reverse('register_code_redemption', args=[registration_code.code]) + url = '{base_url}{redemption_url}'.format(base_url=site_name, redemption_url=redemption_url) + csv_writer.writerow([course.display_name, registration_code.code, url]) + + return csv_file, course_info + + def send_confirmation_emails(self, orderitems, is_order_type_business, csv_file, site_name, courses_info): + """ + send confirmation e-mail + """ + recipient_list = [(self.user.username, getattr(self.user, 'email'), 'user')] # pylint: disable=E1101 + if self.company_contact_email: + recipient_list.append((self.company_contact_name, self.company_contact_email, 'company_contact')) + joined_course_names = "" + if self.recipient_email: + recipient_list.append((self.recipient_name, self.recipient_email, 'email_recipient')) + courses_names_with_dates = [course_info[0] + course_info[1] for course_info in courses_info] + joined_course_names = " " + ", ".join(courses_names_with_dates) + + if not is_order_type_business: + subject = _("Order Payment Confirmation") + else: + subject = _('Confirmation and Registration Codes for the following courses: {course_name_list}').format( + course_name_list=joined_course_names + ) + + dashboard_url = '{base_url}{dashboard}'.format( + base_url=site_name, + dashboard=reverse('dashboard') + ) + try: + from_address = microsite.get_value( + 'email_from_address', + settings.PAYMENT_SUPPORT_EMAIL + ) + # send a unique email for each recipient, don't put all email addresses in a single email + for recipient in recipient_list: + message = render_to_string( + 'emails/business_order_confirmation_email.txt' if is_order_type_business else 'emails/order_confirmation_email.txt', + { + 'order': self, + 'recipient_name': recipient[0], + 'recipient_type': recipient[2], + 'site_name': site_name, + 'order_items': orderitems, + 'course_names': ", ".join([course_info[0] for course_info in courses_info]), + 'dashboard_url': dashboard_url, + 'order_placed_by': '{username} ({email})'.format(username=self.user.username, email=getattr(self.user, 'email')), # pylint: disable=E1101 + 'has_billing_info': settings.FEATURES['STORE_BILLING_INFO'], + 'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME), + 'payment_support_email': microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL), + 'payment_email_signature': microsite.get_value('payment_email_signature'), + } + ) + email = EmailMessage( + subject=subject, + body=message, + from_email=from_address, + to=[recipient[1]] + ) + email.content_subtype = "html" + if csv_file: + email.attach(u'RegistrationCodesRedemptionUrls.csv', csv_file.getvalue(), 'text/csv') + email.send() + except (smtplib.SMTPException, BotoServerError): # sadly need to handle diff. mail backends individually + log.error('Failed sending confirmation e-mail for order %d', self.id) # pylint: disable=E1101 + def purchase(self, first='', last='', street1='', street2='', city='', state='', postalcode='', country='', ccnum='', cardtype='', processor_reply_dump=''): """ @@ -200,29 +361,48 @@ class Order(models.Model): # this should return all of the objects with the correct types of the # subclasses orderitems = OrderItem.objects.filter(order=self).select_subclasses() + site_name = microsite.get_value('SITE_NAME', settings.SITE_NAME) + + if self.order_type == OrderTypes.BUSINESS: + self.update_order_type() + for item in orderitems: item.purchase_item() - # send confirmation e-mail - subject = _("Order Payment Confirmation") - message = render_to_string( - 'emails/order_confirmation_email.txt', - { - 'order': self, - 'order_items': orderitems, - 'has_billing_info': settings.FEATURES['STORE_BILLING_INFO'] - } - ) - try: - from_address = microsite.get_value( - 'email_from_address', - settings.DEFAULT_FROM_EMAIL - ) + csv_file = None + courses_info = [] + if self.order_type == OrderTypes.BUSINESS: + # + # Generate the CSV file that contains all of the RegistrationCodes that have already been + # generated when the purchase has transacted + # + csv_file, courses_info = self.generate_registration_codes_csv(orderitems, site_name) - send_mail(subject, message, - from_address, [self.user.email]) # pylint: disable=E1101 - except (smtplib.SMTPException, BotoServerError): # sadly need to handle diff. mail backends individually - log.error('Failed sending confirmation e-mail for order %d', self.id) # pylint: disable=E1101 + self.send_confirmation_emails(orderitems, self.order_type == OrderTypes.BUSINESS, csv_file, site_name, courses_info) + + def add_billing_details(self, company_name='', company_contact_name='', company_contact_email='', recipient_name='', + recipient_email='', customer_reference_number=''): + """ + This function is called after the user selects a purchase type of "Business" and + is asked to enter the optional billing details. The billing details are updated + for that order. + + company_name - Name of purchasing organization + company_contact_name - Name of the key contact at the company the sale was made to + company_contact_email - Email of the key contact at the company the sale was made to + recipient_name - Name of the company should the invoice be sent to + recipient_email - Email of the company should the invoice be sent to + customer_reference_number - purchase order number of the organization associated with this Order + """ + + self.company_name = company_name + self.company_contact_name = company_contact_name + self.company_contact_email = company_contact_email + self.recipient_name = recipient_name + self.recipient_email = recipient_email + self.customer_reference_number = customer_reference_number + + self.save() def generate_receipt_instructions(self): """ @@ -420,6 +600,16 @@ class RegistrationCodeRedemption(models.Model): redeemed_by = models.ForeignKey(User, db_index=True) redeemed_at = models.DateTimeField(default=datetime.now(pytz.utc), null=True) + @classmethod + def delete_registration_redemption(cls, user, cart): + """ + This method delete registration redemption + """ + reg_code_redemption = cls.objects.filter(redeemed_by=user, order=cart) + if reg_code_redemption: + reg_code_redemption.delete() + log.info('Registration code redemption entry removed for user {0} for order {1}'.format(user, cart.id)) + @classmethod def add_reg_code_redemption(cls, course_reg_code, order): """ @@ -502,6 +692,16 @@ class CouponRedemption(models.Model): user = models.ForeignKey(User, db_index=True) coupon = models.ForeignKey(Coupon, db_index=True) + @classmethod + def delete_coupon_redemption(cls, user, cart): + """ + This method delete coupon redemption + """ + coupon_redemption = cls.objects.filter(user=user, order=cart) + if coupon_redemption: + coupon_redemption.delete() + log.info('Coupon redemption entry removed for user {0} for order {1}'.format(user, cart.id)) + @classmethod def get_discount_price(cls, percentage_discount, value): """ @@ -665,6 +865,142 @@ class PaidCourseRegistration(OrderItem): return u"" +class CourseRegCodeItem(OrderItem): + """ + This is an inventory item for paying for + generating course registration codes + """ + course_id = CourseKeyField(max_length=128, db_index=True) + mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG) + + @classmethod + def contained_in_order(cls, order, course_id): + """ + Is the course defined by course_id contained in the order? + """ + return course_id in [ + item.course_id + for item in order.orderitem_set.all().select_subclasses("courseregcodeitem") + if isinstance(item, cls) + ] + + @classmethod + def get_total_amount_of_purchased_item(cls, course_key): + """ + This will return the total amount of money that a purchased course generated + """ + total_cost = 0 + result = cls.objects.filter(course_id=course_key, status='purchased').aggregate(total=Sum('unit_cost', field='qty * unit_cost')) # pylint: disable=E1101 + + if result['total'] is not None: + total_cost = result['total'] + + return total_cost + + @classmethod + @transaction.commit_on_success + def add_to_order(cls, order, course_id, qty, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): # pylint: disable=W0221 + """ + A standardized way to create these objects, with sensible defaults filled in. + Will update the cost if called on an order that already carries the course. + + Returns the order item + """ + # First a bunch of sanity checks + course = modulestore().get_course(course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + if not course: + log.error("User {} tried to add non-existent course {} to cart id {}" + .format(order.user.email, course_id, order.id)) + raise CourseDoesNotExistException + + if cls.contained_in_order(order, course_id): + log.warning("User {} tried to add PaidCourseRegistration for course {}, already in cart id {}" + .format(order.user.email, course_id, order.id)) + raise ItemAlreadyInCartException + + if CourseEnrollment.is_enrolled(user=order.user, course_key=course_id): + log.warning("User {} trying to add course {} to cart id {}, already registered" + .format(order.user.email, course_id, order.id)) + raise AlreadyEnrolledInCourseException + + ### Validations done, now proceed + ### handle default arguments for mode_slug, cost, currency + course_mode = CourseMode.mode_for_course(course_id, mode_slug) + if not course_mode: + # user could have specified a mode that's not set, in that case return the DEFAULT_MODE + course_mode = CourseMode.DEFAULT_MODE + if not cost: + cost = course_mode.min_price + if not currency: + currency = course_mode.currency + + super(CourseRegCodeItem, cls).add_to_order(order, course_id, cost, currency=currency) + + item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) # pylint: disable=W0612 + item.status = order.status + item.mode = course_mode.slug + item.unit_cost = cost + item.qty = qty + item.line_desc = _(u'Enrollment codes for Course: {course_name}').format( + course_name=course.display_name_with_default) + item.currency = currency + order.currency = currency + item.report_comments = item.csv_report_comments + order.save() + item.save() + log.info("User {} added course registration {} to cart: order {}" + .format(order.user.email, course_id, order.id)) + return item + + def purchased_callback(self): + """ + The purchase is completed, this OrderItem type will generate Registration Codes that will + be redeemed by users + """ + if not modulestore().has_course(self.course_id): + raise PurchasedCallbackException( + "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + total_registration_codes = int(self.qty) + + # we need to import here because of a circular dependency + # we should ultimately refactor code to have save_registration_code in this models.py + # file, but there's also a shared dependency on a random string generator which + # is in another PR (for another feature) + from instructor.views.api import save_registration_code + for i in range(total_registration_codes): # pylint: disable=W0612 + save_registration_code(self.user, self.course_id, invoice=None, order=self.order) + + log.info("Enrolled {0} in paid course {1}, paid ${2}" + .format(self.user.email, self.course_id, self.line_cost)) # pylint: disable=E1101 + + @property + def csv_report_comments(self): + """ + Tries to fetch an annotation associated with the course_id from the database. If not found, returns u"". + Otherwise returns the annotation + """ + try: + return CourseRegCodeItemAnnotation.objects.get(course_id=self.course_id).annotation + except CourseRegCodeItemAnnotation.DoesNotExist: + return u"" + + +class CourseRegCodeItemAnnotation(models.Model): + """ + A model that maps course_id to an additional annotation. This is specifically needed because when Stanford + generates report for the paid courses, each report item must contain the payment account associated with a course. + And unfortunately we didn't have the concept of a "SKU" or stock item where we could keep this association, + so this is to retrofit it. + """ + course_id = CourseKeyField(unique=True, max_length=128, db_index=True) + annotation = models.TextField(null=True) + + def __unicode__(self): + # pylint: disable=no-member + return u"{} : {}".format(self.course_id.to_deprecated_string(), self.annotation) + + class PaidCourseRegistrationAnnotation(models.Model): """ A model that maps course_id to an additional annotation. This is specifically needed because when Stanford @@ -1011,3 +1347,9 @@ class Donation(OrderItem): # The donation is for the organization as a whole, not a specific course else: return _(u"Donation for {platform_name}").format(platform_name=settings.PLATFORM_NAME) + + @property + def single_item_receipt_context(self): + return { + 'receipt_has_donation_item': True, + } diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 14f2369a4df9fee92b8d1d56f8ca4456d53e77c8..d2d992b89fe079dfcce9491dbe1f1e16fab01e2a 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -19,15 +19,17 @@ from xmodule.modulestore.tests.django_utils import ( ModuleStoreTestCase, mixed_store_config ) from xmodule.modulestore.tests.factories import CourseFactory + from shoppingcart.models import ( Order, OrderItem, CertificateItem, - InvalidCartItem, PaidCourseRegistration, + InvalidCartItem, CourseRegistrationCode, PaidCourseRegistration, CourseRegCodeItem, Donation, OrderItemSubclassPK ) from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode -from shoppingcart.exceptions import PurchasedCallbackException, CourseDoesNotExistException +from shoppingcart.exceptions import (PurchasedCallbackException, CourseDoesNotExistException, + ItemAlreadyInCartException, AlreadyEnrolledInCourseException) from opaque_keys.edx.locator import CourseLocator @@ -63,21 +65,21 @@ class OrderTest(ModuleStoreTestCase): item = OrderItem(order=cart, user=self.user) item.save() self.assertTrue(Order.user_cart_has_items(self.user)) - self.assertFalse(Order.user_cart_has_items(self.user, CertificateItem)) - self.assertFalse(Order.user_cart_has_items(self.user, PaidCourseRegistration)) + self.assertFalse(Order.user_cart_has_items(self.user, [CertificateItem])) + self.assertFalse(Order.user_cart_has_items(self.user, [PaidCourseRegistration])) def test_user_cart_has_paid_course_registration_items(self): cart = Order.get_cart_for_user(self.user) item = PaidCourseRegistration(order=cart, user=self.user) item.save() - self.assertTrue(Order.user_cart_has_items(self.user, PaidCourseRegistration)) - self.assertFalse(Order.user_cart_has_items(self.user, CertificateItem)) + self.assertTrue(Order.user_cart_has_items(self.user, [PaidCourseRegistration])) + self.assertFalse(Order.user_cart_has_items(self.user, [CertificateItem])) def test_user_cart_has_certificate_items(self): cart = Order.get_cart_for_user(self.user) CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - self.assertTrue(Order.user_cart_has_items(self.user, CertificateItem)) - self.assertFalse(Order.user_cart_has_items(self.user, PaidCourseRegistration)) + self.assertTrue(Order.user_cart_has_items(self.user, [CertificateItem])) + self.assertFalse(Order.user_cart_has_items(self.user, [PaidCourseRegistration])) def test_cart_clear(self): cart = Order.get_cart_for_user(user=self.user) @@ -189,7 +191,7 @@ class OrderTest(ModuleStoreTestCase): def test_purchase_item_email_smtp_failure(self, error_logger): cart = Order.get_cart_for_user(user=self.user) CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor') - with patch('shoppingcart.models.send_mail', side_effect=smtplib.SMTPException): + with patch('shoppingcart.models.EmailMessage.send', side_effect=smtplib.SMTPException): cart.purchase() self.assertTrue(error_logger.called) @@ -326,6 +328,15 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): self.assertEqual(self.cart.total_cost, self.cost) + def test_cart_type_business(self): + self.cart.order_type = 'business' + self.cart.save() + item = CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2) + self.cart.purchase() + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_key)) + # check that the registration codes are generated against the order + self.assertEqual(len(CourseRegistrationCode.objects.filter(order=self.cart)), item.qty) + def test_add_with_default_mode(self): """ Tests add_to_cart where the mode specified in the argument is NOT in the database @@ -341,6 +352,31 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): self.assertEqual(self.cart.total_cost, 0) self.assertTrue(PaidCourseRegistration.contained_in_order(self.cart, self.course_key)) + course_reg_code_item = CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2, mode_slug="DNE") + + self.assertEqual(course_reg_code_item.unit_cost, 0) + self.assertEqual(course_reg_code_item.line_cost, 0) + self.assertEqual(course_reg_code_item.mode, "honor") + self.assertEqual(course_reg_code_item.user, self.user) + self.assertEqual(course_reg_code_item.status, "cart") + self.assertEqual(self.cart.total_cost, 0) + self.assertTrue(CourseRegCodeItem.contained_in_order(self.cart, self.course_key)) + + def test_add_course_reg_item_with_no_course_item(self): + fake_course_id = CourseLocator(org="edx", course="fake", run="course") + with self.assertRaises(CourseDoesNotExistException): + CourseRegCodeItem.add_to_order(self.cart, fake_course_id, 2) + + def test_course_reg_item_already_in_cart(self): + CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2) + with self.assertRaises(ItemAlreadyInCartException): + CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2) + + def test_course_reg_item_already_enrolled_in_course(self): + CourseEnrollment.enroll(self.user, self.course_key) + with self.assertRaises(AlreadyEnrolledInCourseException): + CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2) + def test_purchased_callback(self): reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_key) self.cart.purchase() @@ -382,6 +418,12 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): reg1.purchased_callback() self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_key)) + course_reg_code_item = CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2) + course_reg_code_item.course_id = CourseLocator(org="changed1", course="forsome1", run="reason1") + course_reg_code_item.save() + with self.assertRaises(PurchasedCallbackException): + course_reg_code_item.purchased_callback() + def test_user_cart_has_both_items(self): """ This test exists b/c having both CertificateItem and PaidCourseRegistration in an order used to break diff --git a/lms/djangoapps/shoppingcart/tests/test_reports.py b/lms/djangoapps/shoppingcart/tests/test_reports.py index e3f69dee9cbd874d52fce014ea6612e6a51c22c5..80402d33004f1f09a085e08914c6bd4a4568c6db 100644 --- a/lms/djangoapps/shoppingcart/tests/test_reports.py +++ b/lms/djangoapps/shoppingcart/tests/test_reports.py @@ -13,7 +13,8 @@ from django.test.utils import override_settings from course_modes.models import CourseMode from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE -from shoppingcart.models import (Order, CertificateItem, PaidCourseRegistration, PaidCourseRegistrationAnnotation) +from shoppingcart.models import (Order, CertificateItem, PaidCourseRegistration, PaidCourseRegistrationAnnotation, + CourseRegCodeItemAnnotation) from shoppingcart.views import initialize_report from student.tests.factories import UserFactory from student.models import CourseEnrollment @@ -203,6 +204,8 @@ class ItemizedPurchaseReportTest(ModuleStoreTestCase): course_mode2.save() self.annotation = PaidCourseRegistrationAnnotation(course_id=self.course_key, annotation=self.TEST_ANNOTATION) self.annotation.save() + self.course_reg_code_annotation = CourseRegCodeItemAnnotation(course_id=self.course_key, annotation=self.TEST_ANNOTATION) + self.course_reg_code_annotation.save() self.cart = Order.get_cart_for_user(self.user) self.reg = PaidCourseRegistration.add_to_order(self.cart, self.course_key) self.cert_item = CertificateItem.add_to_order(self.cart, self.course_key, self.cost, 'verified') @@ -269,3 +272,9 @@ class ItemizedPurchaseReportTest(ModuleStoreTestCase): Fill in gap in test coverage. __unicode__ method of PaidCourseRegistrationAnnotation """ self.assertEqual(unicode(self.annotation), u'{} : {}'.format(self.course_key.to_deprecated_string(), self.TEST_ANNOTATION)) + + def test_courseregcodeitemannotationannotation_unicode(self): + """ + Fill in gap in test coverage. __unicode__ method of CourseRegCodeItemAnnotation + """ + self.assertEqual(unicode(self.course_reg_code_annotation), u'{} : {}'.format(self.course_key.to_deprecated_string(), self.TEST_ANNOTATION)) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index fae0285673c556ab7cef5f58300bae04d1584201..5c1c16ddb9093a970c0fedddd90cc6704f986a9a 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -1,9 +1,7 @@ """ Tests for Shopping Cart views """ -import json from urlparse import urlparse -from decimal import Decimal from django.http import HttpRequest from django.conf import settings @@ -14,6 +12,7 @@ from django.utils.translation import ugettext as _ from django.contrib.admin.sites import AdminSite from django.contrib.auth.models import Group, User from django.contrib.messages.storage.fallback import FallbackStorage +from django.core import mail from django.core.cache import cache from pytz import UTC @@ -28,7 +27,7 @@ from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.factories import CourseFactory from shoppingcart.views import _can_download_report, _get_date_from_str from shoppingcart.models import ( - Order, CertificateItem, PaidCourseRegistration, + Order, CertificateItem, PaidCourseRegistration, CourseRegCodeItem, Coupon, CourseRegistrationCode, RegistrationCodeRedemption, DonationConfiguration ) @@ -41,6 +40,8 @@ from shoppingcart.processors import render_purchase_form_html from shoppingcart.admin import SoftDeleteCouponAdmin from shoppingcart.views import initialize_report from shoppingcart.tests.payment_fake import PaymentFakeView +from decimal import Decimal +import json def mock_render_purchase_form_html(*args, **kwargs): @@ -133,6 +134,30 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_key.to_deprecated_string()])) self.assertEqual(resp.status_code, 403) + def test_billing_details(self): + billing_url = reverse('billing_details') + self.login_user() + + # page not found error because order_type is not business + resp = self.client.get(billing_url) + self.assertEqual(resp.status_code, 404) + + #chagne the order_type to business + self.cart.order_type = 'business' + self.cart.save() + resp = self.client.get(billing_url) + self.assertEqual(resp.status_code, 200) + + data = {'company_name': 'Test Company', 'company_contact_name': 'JohnDoe', + 'company_contact_email': 'john@est.com', 'recipient_name': 'Mocker', + 'recipient_email': 'mock@germ.com', 'company_address_line_1': 'DC Street # 1', + 'company_address_line_2': '', + 'company_city': 'DC', 'company_state': 'NY', 'company_zip': '22003', 'company_country': 'US', + 'customer_reference_number': 'PO#23'} + + resp = self.client.post(billing_url, data) + self.assertEqual(resp.status_code, 200) + def test_add_course_to_cart_already_in_cart(self): PaidCourseRegistration.add_to_order(self.cart, self.course_key) self.login_user() @@ -148,6 +173,120 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 404) self.assertIn("Discount does not exist against code '{0}'.".format(non_existing_code), resp.content) + def test_valid_qty_greater_then_one_and_purchase_type_should_business(self): + qty = 2 + item = self.add_course_to_user_cart(self.course_key) + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.content) + self.assertEqual(data['total_cost'], item.unit_cost * qty) + cart = Order.get_cart_for_user(self.user) + self.assertEqual(cart.order_type, 'business') + + def test_in_valid_qty_case(self): + # invalid quantity, Quantity must be between 1 and 1000. + qty = 0 + item = self.add_course_to_user_cart(self.course_key) + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) + self.assertEqual(resp.status_code, 400) + self.assertIn("Quantity must be between 1 and 1000.", resp.content) + + # invalid quantity, Quantity must be an integer. + qty = 'abcde' + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) + self.assertEqual(resp.status_code, 400) + self.assertIn("Quantity must be an integer.", resp.content) + + # invalid quantity, Quantity is not present in request + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id}) + self.assertEqual(resp.status_code, 400) + self.assertIn("Quantity must be between 1 and 1000.", resp.content) + + def test_valid_qty_but_item_not_found(self): + qty = 2 + item_id = '-1' + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item_id, 'qty': qty}) + self.assertEqual(resp.status_code, 404) + self.assertEqual('Order item does not exist.', resp.content) + + # now testing the case if item id not found in request, + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'qty': qty}) + self.assertEqual(resp.status_code, 400) + self.assertEqual('Order item not found in request.', resp.content) + + def test_purchase_type_should_be_personal_when_qty_is_one(self): + qty = 1 + item = self.add_course_to_user_cart(self.course_key) + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.content) + self.assertEqual(data['total_cost'], item.unit_cost * 1) + cart = Order.get_cart_for_user(self.user) + self.assertEqual(cart.order_type, 'personal') + + def test_purchase_type_on_removing_item_and_cart_has_item_with_qty_one(self): + qty = 5 + self.add_course_to_user_cart(self.course_key) + item2 = self.add_course_to_user_cart(self.testing_course.id) + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item2.id, 'qty': qty}) + self.assertEqual(resp.status_code, 200) + cart = Order.get_cart_for_user(self.user) + cart_items = cart.orderitem_set.all() + test_flag = False + for cartitem in cart_items: + if cartitem.qty == 5: + test_flag = True + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), {'id': cartitem.id}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(test_flag) + + cart = Order.get_cart_for_user(self.user) + self.assertEqual(cart.order_type, 'personal') + + def test_billing_details_btn_in_cart_when_qty_is_greater_than_one(self): + qty = 5 + item = self.add_course_to_user_cart(self.course_key) + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) + self.assertEqual(resp.status_code, 200) + resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) + self.assertIn("Billing Details", resp.content) + + def test_purchase_type_should_be_personal_when_remove_all_items_from_cart(self): + item1 = self.add_course_to_user_cart(self.course_key) + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item1.id, 'qty': 2}) + self.assertEqual(resp.status_code, 200) + + item2 = self.add_course_to_user_cart(self.testing_course.id) + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item2.id, 'qty': 5}) + self.assertEqual(resp.status_code, 200) + + cart = Order.get_cart_for_user(self.user) + cart_items = cart.orderitem_set.all() + test_flag = False + for cartitem in cart_items: + test_flag = True + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), {'id': cartitem.id}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(test_flag) + + cart = Order.get_cart_for_user(self.user) + self.assertEqual(cart.order_type, 'personal') + + def test_use_valid_coupon_code_and_qty_is_greater_than_one(self): + qty = 5 + item = self.add_course_to_user_cart(self.course_key) + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), {'ItemId': item.id, 'qty': qty}) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.content) + self.assertEqual(data['total_cost'], item.unit_cost * qty) + + # use coupon code + self.add_coupon(self.course_key, True, self.coupon_code) + resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) + item = self.cart.orderitem_set.all().select_subclasses()[0] + self.assertEquals(item.unit_cost * qty, 180) + def test_course_discount_invalid_reg_code(self): self.add_reg_code(self.course_key) self.add_course_to_user_cart(self.course_key) @@ -319,6 +458,36 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): info_log.assert_called_with( 'Coupon "{0}" redemption entry removed for user "{1}" for order item "{2}"'.format(self.coupon_code, self.user, reg_item.id)) + @patch('shoppingcart.views.log.info') + def test_reset_redemption_for_coupon(self, info_log): + + self.add_coupon(self.course_key, True, self.coupon_code) + reg_item = self.add_course_to_user_cart(self.course_key) + + resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + + resp = self.client.post(reverse('shoppingcart.views.reset_code_redemption', args=[])) + + self.assertEqual(resp.status_code, 200) + info_log.assert_called_with( + 'Coupon redemption entry removed for user {0} for order {1}'.format(self.user, reg_item.id)) + + @patch('shoppingcart.views.log.info') + def test_reset_redemption_for_registration_code(self, info_log): + + self.add_reg_code(self.course_key) + reg_item = self.add_course_to_user_cart(self.course_key) + + resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.reg_code}) + self.assertEqual(resp.status_code, 200) + + resp = self.client.post(reverse('shoppingcart.views.reset_code_redemption', args=[])) + + self.assertEqual(resp.status_code, 200) + info_log.assert_called_with( + 'Registration code redemption entry removed for user {0} for order {1}'.format(self.user, reg_item.id)) + @patch('shoppingcart.views.log.info') def test_existing_reg_code_redemption_on_removing_item(self, info_log): @@ -474,14 +643,14 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) self.assertEqual(resp.status_code, 200) - ((purchase_form_arg_cart,), _) = form_mock.call_args + ((purchase_form_arg_cart,), _) = form_mock.call_args # pylint: disable=W0621 purchase_form_arg_cart_items = purchase_form_arg_cart.orderitem_set.all().select_subclasses() self.assertIn(reg_item, purchase_form_arg_cart_items) self.assertIn(cert_item, purchase_form_arg_cart_items) self.assertEqual(len(purchase_form_arg_cart_items), 2) ((template, context), _) = render_mock.call_args - self.assertEqual(template, 'shoppingcart/list.html') + self.assertEqual(template, 'shoppingcart/shopping_cart.html') self.assertEqual(len(context['shoppingcart_items']), 2) self.assertEqual(context['amount'], 80) self.assertIn("80.00", context['form_html']) @@ -626,7 +795,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) - self.assertIn('Check Out', resp.content) + self.assertIn('Payment', resp.content) self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) @@ -665,13 +834,58 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertIn('FirstNameTesting123', resp.content) self.assertIn('80.00', resp.content) - ((template, context), _) = render_mock.call_args + ((template, context), _) = render_mock.call_args # pylint: disable=W0621 self.assertEqual(template, 'shoppingcart/receipt.html') self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item, context['order_items']) - self.assertIn(cert_item, context['order_items']) + self.assertIn(reg_item, context['shoppingcart_items'][0]) + self.assertIn(cert_item, context['shoppingcart_items'][1]) self.assertFalse(context['any_refunds']) + @patch('shoppingcart.views.render_to_response', render_mock) + def test_courseregcode_item_total_price(self): + self.cart.order_type = 'business' + self.cart.save() + CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2) + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + self.assertEquals(CourseRegCodeItem.get_total_amount_of_purchased_item(self.course_key), 80) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_receipt_success_with_order_type_business(self): + self.cart.order_type = 'business' + self.cart.save() + reg_item = CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2) + self.cart.add_billing_details(company_name='T1Omega', company_contact_name='C1', + company_contact_email='test@t1.com', recipient_email='test@t2.com') + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + + # mail is sent to these emails recipient_email, company_contact_email, order.user.email + self.assertEquals(len(mail.outbox), 3) + + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + self.assertEqual(resp.status_code, 200) + + # when order_type = 'business' the user is not enrolled in the + # course but presented with the enrollment links + self.assertFalse(CourseEnrollment.is_enrolled(self.cart.user, self.course_key)) + self.assertIn('FirstNameTesting123', resp.content) + self.assertIn('80.00', resp.content) + # check for the enrollment codes content + self.assertIn('Please send each professional one of these unique registration codes to enroll into the course.', resp.content) + + ((template, context), _) = render_mock.call_args # pylint: disable=W0621 + self.assertEqual(template, 'shoppingcart/receipt.html') + self.assertEqual(context['order'], self.cart) + self.assertIn(reg_item, context['shoppingcart_items'][0]) + self.assertIn(self.cart.purchase_time.strftime("%B %d, %Y"), resp.content) + self.assertIn(self.cart.company_name, resp.content) + self.assertIn(self.cart.company_contact_name, resp.content) + self.assertIn(self.cart.company_contact_email, resp.content) + self.assertIn(self.cart.recipient_email, resp.content) + self.assertIn("Invoice #{order_id}".format(order_id=self.cart.id), resp.content) + self.assertIn('You have successfully purchased <b>{total_registration_codes} course registration codes' + .format(total_registration_codes=context['total_registration_codes']), resp.content) + @patch('shoppingcart.views.render_to_response', render_mock) def test_show_receipt_success_with_upgrade(self): @@ -705,8 +919,8 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(template, 'shoppingcart/receipt.html') self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item, context['order_items']) - self.assertIn(cert_item, context['order_items']) + self.assertIn(reg_item, context['shoppingcart_items'][0]) + self.assertIn(cert_item, context['shoppingcart_items'][1]) self.assertFalse(context['any_refunds']) course_enrollment = CourseEnrollment.get_or_create_enrollment(self.user, self.course_key) @@ -736,8 +950,8 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): ((template, context), _tmp) = render_mock.call_args self.assertEqual(template, 'shoppingcart/receipt.html') self.assertEqual(context['order'], self.cart) - self.assertIn(reg_item, context['order_items']) - self.assertIn(cert_item, context['order_items']) + self.assertIn(reg_item, context['shoppingcart_items'][0]) + self.assertIn(cert_item, context['shoppingcart_items'][1]) self.assertTrue(context['any_refunds']) @patch('shoppingcart.views.render_to_response', render_mock) @@ -869,6 +1083,12 @@ class RegistrationCodeRedemptionCourseEnrollment(ModuleStoreTestCase): response = self.client.post(redeem_url, **{'HTTP_HOST': 'localhost'}) self.assertTrue("You've clicked a link for an enrollment code that has already been used." in response.content) + #now check the response of the dashboard page + dashboard_url = reverse('dashboard') + response = self.client.get(dashboard_url) + self.assertEquals(response.status_code, 200) + self.assertTrue(self.course.display_name, response.content) + @override_settings(MODULESTORE=MODULESTORE_CONFIG) @ddt.ddt diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 6d5865f93d5f2e275fedf8c6bf61af5ef72d4c77..02776da0a0f8ce8dfb5f777759d899b9ec454b4f 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -17,6 +17,9 @@ if settings.FEATURES['ENABLE_SHOPPING_CART']: url(r'^add/course/{}/$'.format(settings.COURSE_ID_PATTERN), 'add_course_to_cart', name='add_course_to_cart'), url(r'^register/redeem/(?P<registration_code>[0-9A-Za-z]+)/$', 'register_code_redemption', name='register_code_redemption'), url(r'^use_code/$', 'use_code'), + url(r'^update_user_cart/$', 'update_user_cart'), + url(r'^reset_code_redemption/$', 'reset_code_redemption'), + url(r'^billing_details/$', 'billing_details', name='billing_details'), url(r'^register_courses/$', 'register_courses'), ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 869ebea6a026dbd2189be2c1d53ca7aaf4788702..53c72219ab47c5c69f81f607c3b39c753a1c40dd 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -9,12 +9,13 @@ from django.http import ( HttpResponseBadRequest, HttpResponseForbidden, Http404 ) from django.utils.translation import ugettext as _ +from util.json_request import JsonResponse from django.views.decorators.http import require_POST, require_http_methods from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_exempt -from microsite_configuration import microsite from util.bad_request_rate_limiter import BadRequestRateLimiter from django.contrib.auth.decorators import login_required +from microsite_configuration import microsite from edxmako.shortcuts import render_to_response from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locator import CourseLocator @@ -31,7 +32,8 @@ from .exceptions import ( MultipleCouponsNotAllowedException, InvalidCartItem ) from .models import ( - Order, PaidCourseRegistration, OrderItem, Coupon, + Order, OrderTypes, + PaidCourseRegistration, OrderItem, Coupon, CourseRegCodeItem, CouponRedemption, CourseRegistrationCode, RegistrationCodeRedemption, Donation, DonationConfiguration ) @@ -39,6 +41,7 @@ from .processors import ( process_postpay_callback, render_purchase_form_html, get_signed_purchase_params, get_purchase_endpoint ) + import json from xmodule_django.models import CourseKeyField @@ -90,22 +93,68 @@ def add_course_to_cart(request, course_id): return HttpResponse(_("Course added to cart.")) +@login_required +def update_user_cart(request): + """ + when user change the number-of-students from the UI then + this method Update the corresponding qty field in OrderItem model and update the order_type in order model. + """ + try: + qty = int(request.POST.get('qty', -1)) + except ValueError: + log.exception('Quantity must be an integer.') + return HttpResponseBadRequest('Quantity must be an integer.') + + if not 1 <= qty <= 1000: + log.warning('Quantity must be between 1 and 1000.') + return HttpResponseBadRequest('Quantity must be between 1 and 1000.') + + item_id = request.POST.get('ItemId', None) + if item_id: + try: + item = OrderItem.objects.get(id=item_id, status='cart') + except OrderItem.DoesNotExist: + log.exception('Cart OrderItem id={0} DoesNotExist'.format(item_id)) + return HttpResponseNotFound('Order item does not exist.') + + item.qty = qty + item.save() + item.order.update_order_type() + total_cost = item.order.total_cost + return JsonResponse({"total_cost": total_cost}, 200) + + return HttpResponseBadRequest('Order item not found in request.') + + @login_required def show_cart(request): + """ + This view shows cart items. + """ cart = Order.get_cart_for_user(request.user) total_cost = cart.total_cost - cart_items = cart.orderitem_set.all() + cart_items = cart.orderitem_set.all().select_subclasses() + shoppingcart_items = [] + for cart_item in cart_items: + course_key = getattr(cart_item, 'course_id') + if course_key: + course = get_course_by_id(course_key, depth=0) + shoppingcart_items.append((cart_item, course)) + + site_name = microsite.get_value('SITE_NAME', settings.SITE_NAME) callback_url = request.build_absolute_uri( reverse("shoppingcart.views.postpay_callback") ) form_html = render_purchase_form_html(cart, callback_url=callback_url) context = { - 'shoppingcart_items': cart_items, + 'order': cart, + 'shoppingcart_items': shoppingcart_items, 'amount': total_cost, + 'site_name': site_name, 'form_html': form_html, } - return render_to_response("shoppingcart/list.html", context) + return render_to_response("shoppingcart/shopping_cart.html", context) @login_required @@ -127,22 +176,26 @@ def clear_cart(request): @login_required def remove_item(request): + """ + This will remove an item from the user cart and also delete the corresponding coupon codes redemption. + """ item_id = request.REQUEST.get('id', '-1') - try: - item = OrderItem.objects.get(id=item_id, status='cart') + + items = OrderItem.objects.filter(id=item_id, status='cart').select_subclasses() + + if not len(items): + log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) + else: + item = items[0] if item.user == request.user: - order_item_course_id = None - if hasattr(item, 'paidcourseregistration'): - order_item_course_id = item.paidcourseregistration.course_id + order_item_course_id = getattr(item, 'course_id') item.delete() log.info('order item {0} removed for user {1}'.format(item_id, request.user)) remove_code_redemption(order_item_course_id, item_id, item, request.user) + item.order.update_order_type() - 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 @@ -159,16 +212,30 @@ def remove_code_redemption(order_item_course_id, item_id, item, user): log.info('Coupon "{0}" redemption entry removed for user "{1}" for order item "{2}"' .format(coupon_redemption.coupon.code, user, item_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)) + pass + + 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)) + + +@login_required +def reset_code_redemption(request): + """ + This method reset the code redemption from user cart items. + """ + cart = Order.get_cart_for_user(request.user) + cart.reset_cart_items_prices() + CouponRedemption.delete_coupon_redemption(request.user, cart) + RegistrationCodeRedemption.delete_registration_redemption(request.user, cart) + return HttpResponse('reset') @login_required @@ -448,6 +515,49 @@ def postpay_callback(request): return render_to_response('shoppingcart/error.html', {'order': result['order'], 'error_html': result['error_html']}) + +@require_http_methods(["GET", "POST"]) +@login_required +def billing_details(request): + """ + This is the view for capturing additional billing details + in case of the business purchase workflow. + """ + + cart = Order.get_cart_for_user(request.user) + cart_items = cart.orderitem_set.all() + + if getattr(cart, 'order_type') != OrderTypes.BUSINESS: + raise Http404('Page not found!') + + if request.method == "GET": + callback_url = request.build_absolute_uri( + reverse("shoppingcart.views.postpay_callback") + ) + form_html = render_purchase_form_html(cart, callback_url=callback_url) + total_cost = cart.total_cost + context = { + 'shoppingcart_items': cart_items, + 'amount': total_cost, + 'form_html': form_html, + 'site_name': microsite.get_value('SITE_NAME', settings.SITE_NAME), + } + return render_to_response("shoppingcart/billing_details.html", context) + elif request.method == "POST": + company_name = request.POST.get("company_name", "") + company_contact_name = request.POST.get("company_contact_name", "") + company_contact_email = request.POST.get("company_contact_email", "") + recipient_name = request.POST.get("recipient_name", "") + recipient_email = request.POST.get("recipient_email", "") + customer_reference_number = request.POST.get("customer_reference_number", "") + + cart.add_billing_details(company_name, company_contact_name, company_contact_email, recipient_name, + recipient_email, customer_reference_number) + return JsonResponse({ + 'response': _('success') + }) # status code 200: OK by default + + @login_required def show_receipt(request, ordernum): """ @@ -464,29 +574,61 @@ def show_receipt(request, ordernum): raise Http404('Order not found!') order_items = OrderItem.objects.filter(order=order).select_subclasses() + shoppingcart_items = [] + course_names_list = [] + for order_item in order_items: + course_key = getattr(order_item, 'course_id') + if course_key: + course = get_course_by_id(course_key, depth=0) + shoppingcart_items.append((order_item, course)) + course_names_list.append(course.display_name) + + appended_course_names = ", ".join(course_names_list) any_refunds = any(i.status == "refunded" for i in order_items) receipt_template = 'shoppingcart/receipt.html' __, instructions = order.generate_receipt_instructions() - # we want to have the ability to override the default receipt page when - # there is only one item in the order + order_type = getattr(order, 'order_type') + + # Only orders where order_items.count() == 1 might be attempting to upgrade + attempting_upgrade = request.session.get('attempting_upgrade', False) + if attempting_upgrade: + course_enrollment = CourseEnrollment.get_or_create_enrollment(request.user, order_items[0].course_id) + course_enrollment.emit_event(EVENT_NAME_USER_UPGRADED) + request.session['attempting_upgrade'] = False + + recipient_list = [] + registration_codes = None + total_registration_codes = None + recipient_list.append(getattr(order.user, 'email')) + if order_type == OrderTypes.BUSINESS: + registration_codes = CourseRegistrationCode.objects.filter(order=order) + total_registration_codes = registration_codes.count() + if order.company_contact_email: + recipient_list.append(order.company_contact_email) + if order.recipient_email: + recipient_list.append(order.recipient_email) + + appended_recipient_emails = ", ".join(recipient_list) + context = { 'order': order, - 'order_items': order_items, + 'shoppingcart_items': shoppingcart_items, 'any_refunds': any_refunds, 'instructions': instructions, + 'site_name': microsite.get_value('SITE_NAME', settings.SITE_NAME), + 'order_type': order_type, + 'appended_course_names': appended_course_names, + 'appended_recipient_emails': appended_recipient_emails, + 'total_registration_codes': total_registration_codes, + 'registration_codes': registration_codes, + 'order_purchase_date': order.purchase_time.strftime("%B %d, %Y"), } - + # we want to have the ability to override the default receipt page when + # there is only one item in the order if order_items.count() == 1: receipt_template = order_items[0].single_item_receipt_template context.update(order_items[0].single_item_receipt_context) - # Only orders where order_items.count() == 1 might be attempting to upgrade - attempting_upgrade = request.session.get('attempting_upgrade', False) - if attempting_upgrade: - course_enrollment = CourseEnrollment.get_or_create_enrollment(request.user, order_items[0].course_id) - course_enrollment.emit_event(EVENT_NAME_USER_UPGRADED) - request.session['attempting_upgrade'] = False - return render_to_response(receipt_template, context) diff --git a/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee b/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee index 2f614e6cd4e10c390b93ed4141fd2924abb43b9d..026e321b5b804447571e48fe147621090cc70d46 100644 --- a/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee +++ b/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee @@ -11,6 +11,7 @@ class ECommerce # gather elements @$list_purchase_csv_btn = @$section.find("input[name='list-purchase-transaction-csv']'") @$list_sale_csv_btn = @$section.find("input[name='list-sale-csv']'") + @$list_order_sale_csv_btn = @$section.find("input[name='list-order-sale-csv']'") @$download_company_name = @$section.find("input[name='download_company_name']'") @$active_company_name = @$section.find("input[name='active_company_name']'") @$spent_company_name = @$section.find('input[name="spent_company_name"]') @@ -35,6 +36,10 @@ class ECommerce url += '/csv' location.href = url + @$list_order_sale_csv_btn.click (e) => + url = @$list_order_sale_csv_btn.data 'endpoint' + location.href = url + @$download_coupon_codes.click (e) => url = @$download_coupon_codes.data 'endpoint' location.href = url diff --git a/lms/static/sass/base/_base.scss b/lms/static/sass/base/_base.scss index 8f6cfe98a631bd6034819d243f69fef4dade33d5..4a9fbcc1fcfd7e699c0813fb4fbda35e1a5c95a4 100644 --- a/lms/static/sass/base/_base.scss +++ b/lms/static/sass/base/_base.scss @@ -55,6 +55,15 @@ span { font: inherit; } +.text-center { + text-align: center; +} + +.text-dark-grey { + color: $dark-gray1; + font-size: 24px; +} + p + p, ul + p, ol + p { margin-top: 20px; } diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index a54a4687c7973a7c8ba8aceb880c762539449a3f..91cb01dfdfc0865414edbccfc880711229e2f213 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -421,3 +421,14 @@ $header-sans-serif: 'Open Sans', Arial, Helvetica, sans-serif; // SPLINT: colors $msg-bg: $action-primary-bg; + +// New Shopping Cart + +$dark-gray1: #4a4a4a; +$light-gray1: #f2f2f2; +$light-gray2: #ababab; +$dark-gray2: #979797; +$blue1: #4A90E2; +$blue2: #00A1E5; +$green1: #61A12E; +$red1: #D0021B; diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 6bc0618bddd3274ef8cd373fb1ee0677668648dc..8aebd745be69985ea198eece842122944635b7ce 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -1144,6 +1144,9 @@ input[name="subject"] { } #e-commerce{ + input[name='list-order-sale-csv'] { + margin-right: 14px; + } input { margin-bottom: 1em; line-height: 1.3em; @@ -1292,22 +1295,20 @@ input[name="subject"] { width: 650px; margin-left: -325px; border-radius: 2px; - input[type="submit"]#update_coupon_button{ - @include button(simple, $blue); - @extend .button-reset; - } - input[type="submit"]#add_coupon_button{ + input[type="button"]#update_coupon_button, input[type="button"]#add_coupon_button, + input[type="button"]#set_course_button { @include button(simple, $blue); @extend .button-reset; + display: block; + height: auto; + margin: 0 auto; + width: 100%; + white-space: normal; } input[name="generate-registration-codes-csv"]{ @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; diff --git a/lms/static/sass/views/_shoppingcart.scss b/lms/static/sass/views/_shoppingcart.scss index 1b5119c9a1d1c8a55e0ace3cfd62d3a6a7fce362..65dcd8c00d81677fe59bc3bf2cfd92ad195bedb3 100644 --- a/lms/static/sass/views/_shoppingcart.scss +++ b/lms/static/sass/views/_shoppingcart.scss @@ -30,7 +30,7 @@ font-size: 1.5em; color: $base-font-color; } - + .cart-table { width: 100%; tr:nth-child(even){ @@ -66,7 +66,7 @@ th { text-align: left; border-bottom: 1px solid $border-color-1; - + &.qty { width: 100px; } @@ -87,7 +87,7 @@ } } } - + .cart-items { td { padding: 10px 0px; @@ -115,7 +115,7 @@ padding-right: 20px; } } - + .cart-totals { td { &.cart-total-cost { @@ -127,10 +127,10 @@ } } } - + table.order-receipt { width: 100%; - + .order-number { font-weight: bold; } @@ -147,7 +147,7 @@ th { text-align: left; padding: 25px 0 15px 0; - + &.qty { width: 50px; } @@ -210,7 +210,7 @@ } } .enrollment-text { - color: #4A4A46; + color: #9b9b93; font-family: 'Open Sans',Verdana,Geneva,sans; line-height: normal; a { @@ -264,4 +264,655 @@ text-shadow: 0 1px 0 #00A1E5; font-size: 24px; } -} \ No newline at end of file +} + +.shopping-cart{ + a.blue{ + display: inline-block; + background: $blue2; + color: white; + padding: 20px 40px; + border-radius: 3px; + font-size: 24px; + font-weight: 400; + margin: 10px 0px 20px; + &:hover{ + text-decoration: none; + } + } + .relative{ + position: relative; + } + input[type="text"], input[type="email"] , select{ + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-style: normal; + border: 2px solid $dark-gray2; + height: auto; + padding: 8px 12px; + font-weight: 600; + width: 260px; + font-size: 16px; + &:focus{ + border-color: $dark-gray2; + box-shadow: none; + outline: none; + } + &.error{ + border-color: $red1; + } + } + .hidden{display: none;} + .show{display: inline-block;} + h1{ + font-size: 24px; + color: $dark-gray1; + text-align: left; + padding: 15px 0px; + margin: 10px 0 0 0; + letter-spacing: 0px; + } + ul.steps{ + padding: 0px; + margin: 0; + list-style: none; + border-top: 3px solid $light-gray1; + border-bottom: 3px solid $light-gray1; + li{ + display: inline-block; + padding: 26px 30px; + margin: 0px 30px; + font-size: 20px; + font-weight: 100; + position: relative; + color: $dark-gray1; + &.active{font-weight: 400; border-bottom: 3px solid $light-gray1;} + &:first-child {padding-left: 30px;margin-left: 0;} + &:last-child { + padding-right: 30px;margin-right: 0; + &:after{display: none;} + } + &:after{ + content: "\f178"; + position: absolute; + font-family: FontAwesome; + right: -40px; + color: #ddd; + font-weight: 100; + } + } + } + hr{border-top: 1px solid $dark-gray2;} + .user-data{ + margin: 20px 0px; + .image{ + width: 220px; + float: left; + img{ + width: 100%; + height: auto; + } + } + .data-input{ + width: calc(100% - 245px); + float: left; + margin-left: 25px; + h3, h3 span{ + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-size: 16px; + text-transform: uppercase; + color: $light-gray2; + padding: 0; + } + h1, h1 span{ + font-size: 24px; + color: $dark-gray1; + padding: 0 0 10px 0; + text-transform: capitalize; + span{font-size: 16px;} + } + hr{border-top: 1px solid $dark-gray2;} + .three-col{ + .col-1{ + width: 450px; + float: left; + font-size: 16px; + text-transform: uppercase; + color: $light-gray2; + .price{ + span{ + color: $dark-gray1; + font-size: 24px; + padding-left: 20px; + } + &.green{color: $green1;} + .line-through{text-decoration: line-through;} + } + } + .col-2{ + width: 350px; + float: left; + line-height: 44px; + text-transform: uppercase; + color: $light-gray2; + .numbers-row{ + position: relative; + label{ + font-size: 16px; + text-transform: uppercase; + color: $light-gray2; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-weight: 400; + font-style: normal; + } + .counter{ + margin-left: 25px; + border-radius: 3px; + padding: 6px 30px 6px 10px; + display: inline-block; + border: 2px solid $dark-gray2; + input[type="text"]{ + width: 75px; + border: none; + box-shadow: none; + color: #666; + font-size: 25px; + font-style: normal; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-weight: 600; + padding: 8px 0; + height: auto; + text-align: center; + &:focus{ + outline: none; + } + } + + } + .button{ + position: absolute; + background: none; + margin-left: -30px; + padding: 0; + border: none; + box-shadow: none; + text-shadow: none; + height: 17px; + i{ + color: $dark-gray2; + font-size: 24px; + span{display: none;} + } + &.inc{top: 9px;} + &.dec{top: 30px;height: 22px;} + } + &.disabled{ + .counter{ + border: 2px solid #CCCCCC; + &:hover{ + cursor: not-allowed; + } + input{ + color: #CCC; + } + } + .button{ + i{ + color: #ccc; + } + } + } + .updateBtn{ + display: inline-block; + float: right; + font-size: 15px; + padding: 25px 35px 25px 0; + &:focus{ + outline: none; + } + } + span.error-text{ + display: block; + text-transform: lowercase; + } + } + .disable-numeric-counter{ + pointer-events: none; + } + } + .col-3{ + width: 100px; + float: right; + a.btn-remove{ + float: right; + opacity: 0.8; + i{ + color: $dark-gray2; + font-size: 24PX; + line-height: 40px; + } + &:hover{text-decoration: none;opacity: 1;} + } + } + } + } + + } + .discount{ + border-bottom: 2px solid $light-gray1; + border-top: 2px solid $light-gray1; + margin: 20px 0; + padding: 17px 20px 15px; + min-height: 45px; + .code-text{ + a{ + color: $blue1; + font-size: 18px; + text-transform: lowercase; + font-weight: 600; + display: inline-block; + padding: 10px 0px; + cursor: pointer; + } + span{ + display: inline-block; + padding: 9px 0px; + b{ + font-weight: 600; + font-size: 24px; + padding-left: 20px; + letter-spacing: 0; + } + } + } + + .code-input{ + display: inline-block; + input[type="text"]{ + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-style: normal; + border: 2px solid $dark-gray2; + height: auto; + padding: 8px 12px; + font-weight: 600; + width: 260px; + &:focus{ + border-color: $dark-gray2; + box-shadow: none; + } + &.error{ + border-color: $red1; + } + } + .error-text{ + color: $red1; + font-size: 12px; + display: block; + padding-bottom: 0; + } + input[type="submit"]{ + padding: 9px 35px; + } + } + .code-applied{ + display: inline-block; + .green{ + color: $green1; + font-weight: 600; + margin-right: 20px; + } + input[type="submit"]{ + padding: 9px 35px; + background: white; + border: 2px solid $dark-gray2; + color: $dark-gray2; + box-shadow: none; + text-shadow: none; + &:hover{ + background: white; + color: $dark-gray1; + border: 2px solid $dark-gray2; + } + } + } + input[type="submit"]{ + width: auto; + padding: 7px 20px; + height: auto; + float: none; + font-size: 16px; + letter-spacing: 0; + font-weight: 600; + &:hover{ + background: #1F8FC2; + border: 1px solid transparent; + box-shadow: none; + } + } + } + .col-two{ + overflow: hidden; + padding-bottom: 20px; + border-bottom: 2px solid #f2f2f2; + .row-inside { + float: left; + width: 50%; + padding: 10px 0; + b{ + font-size: 14px; + width: 190px; + display: inline-block; + margin-right: 20px; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + vertical-align: top; + } + label{ + width: 300px; + margin: 0px; + display: inline-block; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-style: normal; + font-size: 14px; + word-wrap: break-word; + } + } + .col-1{ + width: 35%; + float: left; + span.radio-group{ + display: inline-block; + border: 2px solid #979797; + border-radius: 3px; + margin: 10px 0; + margin-left: 5px; + &:first-child{ + margin-left: 15px; + } + &.blue{ + border-color: $blue2; + label{ + color: $blue2; + } + } + label{ + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-size: 16px; + font-style: normal; + color: $dark-gray2; + font-weight: 400; + padding: 8px 15px 8px 6px; + display: inline-block; + margin-bottom: 0; + } + } + input[type="radio"]{ + margin-left: 10px; + } + } + .col-2{ + width: 65%; + float: right; + input[type="submit"]{ + width: auto; + padding: 18px 60px 22px 30px; + height: auto; + font-size: 24px; + letter-spacing: 0; + font-weight: 600; + margin-left: 15px; + &#register{ + padding: 18px 30px; + } + } + p{ + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + padding: 13px 0; + text-align: right; + } + form{ + position: relative; + } + i.icon-caret-right{ + position: absolute; + right: 30px; + top: 25px; + color: white; + font-size: 24px; + } + + label.pull-right{ + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-style: normal; + text-align: right; + padding: 10px 25px 10px; + display: inline-block; + float: right; + line-height: 20px; + color: $dark-gray1; + } + } + } + .disclaimer{ + color: $light-gray2; + padding: 10px 0px; + text-align: right; + font-weight: 300; + } + h3{ + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-size: 16px; + font-weight: 400; + padding: 30px 20px; + color: $dark-gray1; + } + .billing-data{ + display: table; + width: 100%; + h3{ + padding: 12px 0px; + color: $dark-gray1; + font-size: 17px; + margin-bottom: 5px; + } + .row{ + display: table-row; + } + .col-half{ + width: 45%; + float: left; + background: $light-gray1; + padding: 20px; + border-radius: 4px; + margin-bottom: 15px; + min-height: 240px; + &:nth-child(even){ + margin-left: 30px; + } + .data-group{ + margin-bottom: 15px; + label{ + display: block; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-size: 16px; + font-style: normal; + font-weight: 400; + color: $dark-gray2; + } + input{width: 100%;margin-bottom: 5px;} + &:nth-child(4n){ + margin-right: 0px; + } + } + } + } + .error-text{ + color: $red1; + font-size: 12px; + display: block; + padding-bottom: 0; + } + .gray-bg{ + background: $light-gray1; + border-radius: 3px; + padding: 20px 20px 20px 30px; + margin: 20px 0; + overflow: hidden; + .message-left{ + float: left; + line-height: 24px; + color: $dark-gray1; + b{ + text-transform: capitalize; + } + a.blue{ + margin:0 0 0 20px; + i{ + margin-left: 10px; + } + } + } + } + .bordered-bar{ + border-bottom: 2px solid $light-gray1; + border-top: 2px solid $light-gray1; + margin-bottom: 20px; + padding: 20px; + h2{ + color: $dark-gray1; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-weight: bold; + margin-bottom: 0; + font-size: 17px; + span{ + padding-left: 60px; + text-transform: capitalize; + .blue-link{ + color: $blue2; + font-size: 14px; + &:hover{ + text-decoration: none; + } + } + } + } + } + .pattern{ + margin-top: 10px; + margin-bottom: 20px; + padding:20px; + color: $dark-gray1; + } + hr.border{ + border-top: 2px solid $light-gray1; + } + .no-border{border: none !important; } + table.course-receipt{ + width: 94%; + margin: auto; + margin-bottom: 27px; + thead{ + th{ + color: $light-gray2; + font-weight: normal; + text-align: center; + text-transform: uppercase; + padding: 8px 0; + border-bottom: 1px solid $dark-gray2; + &:first-child{ + text-align: left; + } + &:last-child{ + text-align: right; + } + } + } + tr{ + border-bottom: 1px solid $light-gray1; + &:last-child{ + border-bottom: none; + } + td{ + padding: 15px 0; + text-align: center; + color: $dark-gray1; + width: 33.33333%; + + &:first-child{ + text-align: left; + font-size: 18px; + text-transform: capitalize; + } + &:last-child{ + text-align: right; + } + } + } + } +} +.empty-cart{ + padding: 20px 0px; + background: $light-gray1; + text-align: center; + border-radius: 3px; + margin: 20px 0px; + h2{ + font-size: 24PX; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif; + font-weight: 600; + letter-spacing: 0; + color: #9b9b9b; + text-align: center; + margin-top: 20px; + text-transform: initial; + } + a.blue{ + display: inline-block; + background: $blue2; + color: white; + padding: 20px 40px; + border-radius: 3px; + font-size: 24px; + font-weight: 400; + margin: 10px 0px 20px; + &:hover{ + text-decoration: none; + } + } +} + +// Print + +@media print{ + a[href]:after { + content: none !important; + } + ul.steps, a.blue.pull-right, .bordered-bar span.pull-right, .left.nav-global.authenticated { + display: none; + } + .shopping-cart{ + font-size: 14px; + padding-right: 40px; + .gray-bg{ + margin: 0; + padding: 10px 0 20px 0; + background: none; + .message-left{ + width: 100%; + } + } + .bordered-bar{ + h2{ + font-size: 14px; + } + span{ + float: right; + } + } + .user-data{ + .data-input{ + h1{ + font-size: 18px; + } + } + } + } +} diff --git a/lms/templates/emails/business_order_confirmation_email.txt b/lms/templates/emails/business_order_confirmation_email.txt new file mode 100644 index 0000000000000000000000000000000000000000..870ca26c986edc5cee341dbd9bf2e1a783e78a46 --- /dev/null +++ b/lms/templates/emails/business_order_confirmation_email.txt @@ -0,0 +1,93 @@ +<%! from django.utils.translation import ugettext as _ %> +<p> +${_("Hi {name},").format(name=recipient_name)} +</p> +<p> +${_("Thank you for your purchase of ")} <b> ${course_names} </b> +</p> +%if recipient_type == 'user': +<p>${_("Your payment was successful.")}</p> +% if marketing_link('FAQ'): +<p>${_("If you have billing questions, please read the FAQ ({faq_url}) or contact {billing_email}.").format(billing_email=payment_support_email, faq_url=marketing_link('FAQ'))}</p> +% else: +<p>${_("If you have billing questions, please contact {billing_email}.").format(billing_email=payment_support_email)}</p> +% endif + +%elif recipient_type == 'company_contact': + +<p>${_("{order_placed_by} placed an order and mentioned your name as the Organization contact.").format(order_placed_by=order_placed_by)}</p> + +%elif recipient_type == 'email_recipient': + +<p>${_("{order_placed_by} placed an order and mentioned your name as the additional receipt recipient.").format(order_placed_by=order_placed_by)}</p> + +%endif + +<p>${_("The items in your order are:")}</p> + +<p>${_("Quantity - Description - Price")}<br> +%for order_item in order_items: + ${order_item.qty} - ${order_item.line_desc} - ${"$" if order_item.currency == 'usd' else ""}${order_item.line_cost}</p> +%endfor + +<p>${_("Total billed to credit/debit card: {currency_symbol}{total_cost}").format(total_cost=order.total_cost, currency_symbol=("$" if order.currency == 'usd' else ""))}</p> + +<p> +% if order.company_name: +${_('Company Name:')} ${order.company_name}<br> +%endif +% if order.customer_reference_number: +${_('Purchase Order Number:')} ${order.customer_reference_number}<br> +%endif +% if order.company_contact_name: +${_('Company Contact Name:')} ${order.company_contact_name}<br> + %endif +% if order.company_contact_email: +${_('Company Contact Email:')} ${order.company_contact_email}<br> +%endif +% if order.recipient_name: +${_('Recipient Name:')} ${order.recipient_name}<br> + %endif +% if order.recipient_email: +${_('Recipient Email:')} ${order.recipient_email}<br> + %endif + +% if has_billing_info: +${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()} +% endif +</p> +<p>${_("Order Number: {order_number}").format(order_number=order.id)}</p> + +<p><b>${_("A CSV file of your registration URLs is attached. Please distribute registration URLs to each student planning to enroll using the email template below.")}</b></p> + +<p>${_("Warm regards,")}<br> +% if payment_email_signature: +${payment_email_signature} +% else: +${_("The {platform_name} Team").format(platform_name=platform_name)} +%endif +</p> + + +——————————————————————————————————————————— + + +<p>Dear [[Name]]</p> + +<p>To enroll in ${course_names} we have provided a registration URL for you. Please follow the instructions below to claim your access.</p> + +<p>Your redeem url is: [[Enter Redeem URL here from the attached CSV]]</p> + +<p>${_("(1) Register for an account at <a href='https://{site_name}' >https://{site_name}</a>.").format(site_name=site_name)}<br> +${_("(2) Once registered, copy the redeem URL and paste it in your web browser.")}<br> +${_("(3) On the enrollment confirmation page, Click the 'Activate Enrollment Code' button. This will show the enrollment confirmation.")}<br> +${_("(4) You should be able to click on 'view course' button or see your course on your student dashboard at <a href='https://{dashboard_url}'>https://{dashboard_url}</a>").format(dashboard_url=dashboard_url)}<br> +${_("(5) Course materials will not be available until the course start date.")}</p> + +<p>Sincerely,</p> +<p>[[Your Signature]]</p> diff --git a/lms/templates/emails/order_confirmation_email.txt b/lms/templates/emails/order_confirmation_email.txt index 65c0e3a67e71400f71d3942061b5fcf2956d758c..87d4c26b8095e386176d9d7a8b79d5323598a535 100644 --- a/lms/templates/emails/order_confirmation_email.txt +++ b/lms/templates/emails/order_confirmation_email.txt @@ -4,11 +4,17 @@ ${_("Hi {name}").format(name=order.user.profile.name)} ${_("Your payment was successful. You will see the charge below on your next credit or debit card statement.")} ${_("The charge will show up on your statement under the company name {merchant_name}.").format(merchant_name=settings.CC_MERCHANT_NAME)} % if marketing_link('FAQ'): -${_("If you have billing questions, please read the FAQ ({faq_url}) or contact {billing_email}.").format(billing_email=settings.PAYMENT_SUPPORT_EMAIL, faq_url=marketing_link('FAQ'))} +${_("If you have billing questions, please read the FAQ ({faq_url}) or contact {billing_email}.").format(billing_email=payment_support_email, faq_url=marketing_link('FAQ'))} % else: -${_("If you have billing questions, please contact {billing_email}.").format(billing_email=settings.PAYMENT_SUPPORT_EMAIL)} +${_("If you have billing questions, please contact {billing_email}.").format(billing_email=payment_support_email)} % endif -${_("-The {platform_name} Team").format(platform_name=settings.PLATFORM_NAME)} + +${_("Warm regards,")} +% if payment_email_signature: +${payment_email_signature} +% else: +${_("The {platform_name} Team").format(platform_name=platform_name)} +%endif ${_("Your order number is: {order_number}").format(order_number=order.id)} @@ -16,7 +22,7 @@ ${_("The items in your order are:")} ${_("Quantity - Description - Price")} %for order_item in order_items: - ${order_item.qty} - ${order_item.line_desc} - ${"$" if order_item.currency == 'usd' else ""}${order_item.line_cost} + ${order_item.qty} - ${order_item.line_desc} - ${"$" if order_item.currency == 'usd' else ""}${order_item.line_cost} %endfor ${_("Total billed to credit/debit card: {currency_symbol}{total_cost}").format(total_cost=order.total_cost, currency_symbol=("$" if order.currency == 'usd' else ""))} diff --git a/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html b/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html index 82990bd8e5a8a8e22fd9fac595a78af11f7adbad..04b8c148069654c6093b350ef0f16517c2439f5e 100644 --- a/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html +++ b/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html @@ -1,7 +1,7 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> <%page args="section_data"/> -<section id="add-coupon-modal" class="modal" role="dialog" tabindex="-1" aria-label="${_('Password Reset')}"> +<section id="add-coupon-modal" class="modal" role="dialog" tabindex="-1" aria-label="${_('Add Coupon')}"> <div class="inner-wrapper"> <button class="close-modal"> <i class="icon-remove"></i> @@ -21,7 +21,7 @@ ${_("Please enter Coupon detail below")}</p> </div> - <form id="add_coupon_form" action="${section_data['ajax_add_coupon']}" method="post" data-remote="true"> + <form id="add_coupon_form"> <div id="coupon_form_error" class="modal-form-error"></div> <fieldset class="group group-form group-form-requiredinformation"> <legend class="is-hidden">${_("Required Information")}</legend> @@ -54,7 +54,7 @@ </fieldset> <div class="submit"> - <input name="submit" type="submit" id="add_coupon_button" value="${_('Add Coupon')}"/> + <input name="submit" type="button" id="add_coupon_button" value="${_('Add Coupon')}"/> </div> </form> </div> diff --git a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html index 37af5ad4b52e0a991eb419f9133910d3cdf81b5b..7a2dda941d5eea22dcadcde9933a017ab8467e08 100644 --- a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html +++ b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html @@ -68,8 +68,12 @@ <div class="wrap"> <h2>${_("Sales")}</h2> <div> - <span class="csv_tip">${_("Click to generate a CSV file for all sales records in this course")} - <input type="button" class="add blue-button" name="list-sale-csv" value="${_("Download All e-Commerce Sales")}" data-endpoint="${ section_data['get_sale_records_url'] }" data-csv="true"></p></td> + <span class="csv_tip"> + <div > + ${_("Click to generate a CSV file for all sales records in this course")} + <input type="button" class="add blue-button" name="list-sale-csv" value="${_("Download All Invoice Sales")}" data-endpoint="${ section_data['get_sale_records_url'] }" data-csv="true"> + <input type="button" class="add blue-button" name="list-order-sale-csv" value="${_("Download All Order Sales")}" data-endpoint="${ section_data['get_sale_order_records_url'] }" data-csv="true"> + </div> </span> <hr> <p>${_("Enter the invoice number to invalidate or re-validate sale")}</p> @@ -384,8 +388,28 @@ modal_overLay.hide(); }); - $('#edit_coupon_form').submit(function () { + $('#update_coupon_button').click(function () { $("#update_coupon_button").attr('disabled', true); + var coupon_id = $.trim($('#coupon_id').val()); + var description = $.trim($('#edit_coupon_description').val()); + + $.ajax({ + type: "POST", + data: { + "coupon_id" : coupon_id, + "description": description + }, + url: "${section_data['ajax_update_coupon']}", + success: function (data) { + location.reload(true); + }, + error: function(jqXHR, textStatus, errorThrown) { + var data = $.parseJSON(jqXHR.responseText); + $("#update_coupon_button").removeAttr('disabled'); + $('#edit_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#edit_coupon_form #coupon_form_error').text(data.message); + } + }); }); $('#course_price_link').click(function () { reset_input_fields(); @@ -397,7 +421,7 @@ reset_input_fields(); $('input[name="generate-registration-codes-csv"]').removeAttr('disabled'); }); - $('#set_price_form').submit(function () { + $('#set_course_button').click(function () { $("#set_course_button").attr('disabled', true); // Get the Code and Discount value and trim it var course_price = $.trim($('#mode_price').val()); @@ -422,12 +446,31 @@ $("#set_course_button").removeAttr('disabled'); return false; } + $.ajax({ + type: "POST", + data: { + "course_price" : course_price, + "currency": currency + }, + url: "${section_data['set_course_mode_url']}", + success: function (data) { + location.reload(true); + }, + error: function(jqXHR, textStatus, errorThrown) { + var data = $.parseJSON(jqXHR.responseText); + $("#set_course_button").removeAttr('disabled'); + $('#set_price_form #course_form_error').attr('style', 'display: block !important'); + $('#set_price_form #course_form_error').text(data.message); + } + }); }); - $('#add_coupon_form').submit(function () { + $('#add_coupon_button').click(function () { $("#add_coupon_button").attr('disabled', true); // Get the Code and Discount value and trim it var code = $.trim($('#coupon_code').val()); var coupon_discount = $.trim($('#coupon_discount').val()); + var course_id = $.trim($('#coupon_course_id').val()); + var description = $.trim($('#coupon_description').val()); // Check if empty of not if (code === '') { @@ -448,36 +491,25 @@ $('#add_coupon_form #coupon_form_error').text("${_('Please enter the numeric value for discount')}"); return false; } - }); - - $('#set_price_form').on('ajax:complete', function (event, xhr) { - if (xhr.status == 200) { - location.reload(true); - } else { - $("#set_course_button").removeAttr('disabled'); - $('#set_price_form #course_form_error').attr('style', 'display: block !important'); - $('#set_price_form #course_form_error').text(xhr.responseText); - } - }); - - $('#add_coupon_form').on('ajax:complete', function (event, xhr) { - if (xhr.status == 200) { - location.reload(true); - } else { - $("#add_coupon_button").removeAttr('disabled'); - $('#add_coupon_form #coupon_form_error').attr('style', 'display: block !important'); - $('#add_coupon_form #coupon_form_error').text(xhr.responseText); - } - }); - - $('#edit_coupon_form').on('ajax:complete', function (event, xhr) { - if (xhr.status == 200) { - location.reload(true); - } else { - $("#update_coupon_button").removeAttr('disabled'); - $('#edit_coupon_form #coupon_form_error').attr('style', 'display: block !important'); - $('#edit_coupon_form #coupon_form_error').text(xhr.responseText); - } + $.ajax({ + type: "POST", + data: { + "code" : code, + "discount": coupon_discount, + "course_id": course_id, + "description": description + }, + url: "${section_data['ajax_add_coupon']}", + success: function (data) { + location.reload(true); + }, + error: function(jqXHR, textStatus, errorThrown) { + var data = $.parseJSON(jqXHR.responseText); + $('#add_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#add_coupon_form #coupon_form_error').text(data.message); + $("#add_coupon_button").removeAttr('disabled'); + } + }); }); // removing close link's default behavior $('.close-modal').click(function (e) { diff --git a/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html b/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html index 8052d7be710a4ecdaf690d5daf2fb0fcd2cd421e..b0345c917face8a5ddf9eee03916232eb87d6072 100644 --- a/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html +++ b/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html @@ -54,7 +54,7 @@ <div class="submit"> <input type="hidden" name="coupon_id" id="coupon_id"/> - <input name="submit" type="submit" id="update_coupon_button" value="${_('Update Coupon')}"/> + <input name="submit" type="button" id="update_coupon_button" value="${_('Update Coupon')}"/> </div> </form> </div> diff --git a/lms/templates/instructor/instructor_dashboard_2/set_course_mode_price_modal.html b/lms/templates/instructor/instructor_dashboard_2/set_course_mode_price_modal.html index f0c37f97cc0ebc60513e5699b19e5e2852eabbc0..5c4a52ab49396d030d6c97c6acc06d163087b95d 100644 --- a/lms/templates/instructor/instructor_dashboard_2/set_course_mode_price_modal.html +++ b/lms/templates/instructor/instructor_dashboard_2/set_course_mode_price_modal.html @@ -21,7 +21,7 @@ ${_("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"> + <form id="set_price_form"> <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> @@ -40,7 +40,7 @@ </ol> </fieldset> <div class="submit"> - <input name="submit" type="submit" id="set_course_button" value="${_('Set Price')}"/> + <input name="submit" type="button" id="set_course_button" value="${_('Set Price')}"/> </div> </form> </div> diff --git a/lms/templates/shoppingcart/billing_details.html b/lms/templates/shoppingcart/billing_details.html new file mode 100644 index 0000000000000000000000000000000000000000..d1dbf4e54febd5283c19e8fb759154f6cbc89f17 --- /dev/null +++ b/lms/templates/shoppingcart/billing_details.html @@ -0,0 +1,114 @@ +<%inherit file="shopping_cart_flow.html" /> +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%block name="billing_details_highlight"><li class="active" >${_('Billing Details')}</li></%block> +<%block name="confirmation_highlight"></%block> + +<%block name="custom_content"> +<div class="container"> + % if shoppingcart_items: + <section class="confirm-enrollment shopping-cart"> + <h3>${_('You can proceed to payment at any point in time. Any additional information you provide will be included in your receipt.')}</h3> + <div class="billing-data"> + <div class="col-half"> + <h3>${_('Purchasing Organizational Details')}</h3> + <div class="data-group"><label for="id_company_name">${_('Purchasing organization')}</label><input type="text" id="id_company_name" name="company_name"></div> + <div class="data-group"><label for="id_customer_reference_number">${_('Purchase order number (if any)')}</label><input type="text" id="id_customer_reference_number" maxlength="63" name="customer_reference_number"></div> + </div> + <div class="col-half"> + <h3>${_('Organization Contact')}</h3> + <div class="data-group"><label for="id_company_contact_name">${_('Name')}</label><input type="text"id="id_company_contact_name" name="company_contact_name"></div> + <div class="data-group"><label for="id_company_contact_email">${_('Email Address')}</label><input type="email" placeholder="${_('email@example.com')}" id="id_company_contact_email" name="company_contact_email"><span id="company_contact_email_error" class="error-text"></span></div> + </div> + <div class="col-half"> + <h3>${_('Additional Receipt Recipient')}</h3> + <div class="data-group"> + <label for="id_recipient_name">${_('Name')}</label> + <input type="text" id="id_recipient_name" name="recipient_name"> + </div> + <div class="data-group"> + <label for="id_recipient_email">${_('Email Address')}</label> + <input type="email" id="id_recipient_email" placeholder="${_('email@example.com')}" name="recipient_email"> + <span id="recipient_email_error" class="error-text"></span> + </div> + </div> + </div> + <div class="discount"> + <div class="code-text"> + <span class="pull-right">${_('Total')}: <b>$${"{0:0.2f}".format(amount)} USD</b></span> + </div> + </div> + <div class="col-two"> + + <div class="col-2"> + ${form_html} + <p> + ${_('If no additional billing details are populated the payment confirmation will be sent to the user making the purchase')} + </p> + </div> + </div> + <div class="disclaimer">${_('Payment processing occurs on a separate secure site.')}</div> + + </section> + %else: + <div class="empty-cart" > + <h2>${_('Your Shopping cart is currently empty.')}</h2> + <a href="${marketing_link('COURSES')}" class="blue">${_('View Courses')}</a> + </div> + %endif +</div> +</%block> + +<script> + $(function() { + function validateEmail(sEmail) { + filter = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/ + return filter.test(sEmail) + } + $('form input[type="submit"]').click(function(event) { + var is_valid_email = true; + var payment_form = $(this).parent('form'); + var recipient_email = $('input[name="recipient_email"]').val(); + var company_contact_email = $('input[name="company_contact_email"]').val(); + if ( recipient_email != '' && !(validateEmail(recipient_email))) { + $('span#recipient_email_error').html('Please enter valid email address'); + $('input[name="recipient_email"]').addClass('error'); + is_valid_email = false; + } + else { + $('input[name="recipient_email"]').removeClass('error'); + $('span#recipient_email_error').html(''); + } + if ( company_contact_email != '' && !(validateEmail(company_contact_email))) { + $('span#company_contact_email_error').html('Please enter valid email address'); + $('input[name="company_contact_email"]').addClass('error'); + is_valid_email = false; + } + else { + $('input[name="company_contact_email"]').removeClass('error'); + $('span#company_contact_email_error').html(''); + } + if (!is_valid_email) { + return false; + } + event.preventDefault(); + var post_url = "${reverse('billing_details')}"; + var data = { + "company_name" : $('input[name="company_name"]').val(), + "company_contact_name" : $('input[name="company_contact_name"]').val(), + "company_contact_email" : company_contact_email, + "recipient_name" : $('input[name="recipient_name"]').val(), + "recipient_email" : recipient_email, + "customer_reference_number" : $('input[name="customer_reference_number"]').val() + }; + $.post(post_url, data) + .success(function(data) { + payment_form.submit(); + }) + .error(function(data,status) { + + }) + }); + }); +</script> diff --git a/lms/templates/shoppingcart/cybersource_form.html b/lms/templates/shoppingcart/cybersource_form.html index b07af57ab7d24d6088926dd617962cd4638a0998..8a603baa72fb8076f4ec1f8df3eb412602b0a3c5 100644 --- a/lms/templates/shoppingcart/cybersource_form.html +++ b/lms/templates/shoppingcart/cybersource_form.html @@ -3,5 +3,5 @@ <input type="hidden" name="${pk}" value="${pv}" /> % endfor - <input type="submit" value="Check Out" /> + <i class="icon-caret-right"></i><input type="submit" value="Payment"/> </form> \ No newline at end of file diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html deleted file mode 100644 index 632b75d534a237977ff79033f21067c38dd57d91..0000000000000000000000000000000000000000 --- a/lms/templates/shoppingcart/list.html +++ /dev/null @@ -1,129 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -<%! from django.core.urlresolvers import reverse %> - -<%inherit file="../main.html" /> - -<%block name="pagetitle">${_("Your Shopping Cart")}</%block> - -<section class="container cart-list"> - <h2>${_("Your selected items:")}</h2> - <h3 class="cart-errors" id="cart-error">Error goes here.</h3> - % if shoppingcart_items: - <table class="cart-table"> - <thead> - <tr class="cart-headings"> - <th class="dsc">${_("Description")}</th> - <th class="u-pr">${_("Price")}</th> - <th class="cur">${_("Currency")}</th> - <th> </th> - </tr> - </thead> - <tbody> - % for item in shoppingcart_items: - <tr class="cart-items"> - <td>${item.line_desc}</td> - <td> - ${"{0:0.2f}".format(item.unit_cost)} - % if item.list_price != None: - <span class="old-price"> ${"{0:0.2f}".format(item.list_price)}</span> - % endif - </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="4" valign="middle" class="cart-total" align="right"> - <b>${_("Total Amount")}: <span> ${"{0:0.2f}".format(amount)} </span> </b> - </td> - </tr> - - </tbody> - <tfoot> - <tr class="always-white"> - <td colspan="2"> - <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"> - % if amount == 0: - <input type="button" value = "Register" id="register" > - % else: - ${form_html} - %endif - </td> - </tr> - - </tfoot> - </table> - <!-- <input id="back_input" type="submit" value="Return" /> --> - % else: - <p>${_("You have selected no items for purchase.")}</p> - % endif - -</section> - - -<script> - $(function() { - $('a.remove_line_item').click(function(event) { - event.preventDefault(); - var post_url = "${reverse('shoppingcart.views.remove_item')}"; - $.post(post_url, {id:$(this).data('item-id')}) - .always(function(data){ - location.reload(true); - }); - }); - - $('#cart-code').click(function(event){ - event.preventDefault(); - var post_url = "${reverse('shoppingcart.views.use_code')}"; - $.post(post_url,{ - "code" : $('#code').val(), - beforeSend: function(xhr, options){ - if($('#code').val() == "") { - showErrorMsgs('Must enter a valid code') - xhr.abort(); - } - } - } - ) - .success(function(data) { - location.reload(true); - }) - .error(function(data,status) { - if(status=="parsererror"){ - location.reload(true); - }else{ - showErrorMsgs(data.responseText) - } - }) - }); - - $('#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(); - }); - - function showErrorMsgs(msg){ - $(".cart-errors").css('display', 'block'); - $("#cart-error").html(msg); - } - }); -</script> \ No newline at end of file diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html index 28c71de7d17fb5de95290bf065a3361bdc6a1584..fac1ef230d19796e4052e7cc7dc775ab11829593 100644 --- a/lms/templates/shoppingcart/receipt.html +++ b/lms/templates/shoppingcart/receipt.html @@ -1,108 +1,356 @@ +<%inherit file="shopping_cart_flow.html" /> <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> -<%! from django.conf import settings %> -<%! from microsite_configuration import microsite %> +<%! +from courseware.courses import course_image_url, get_course_about_section, get_course_by_id +%> -<%inherit file="../main.html" /> - -<%block name="bodyclass">purchase-receipt</%block> - -<%block name="pagetitle">${_("Register for [Course Name] | Receipt (Order")} ${order.id})</%block> +<%block name="billing_details_highlight"> +% if order_type == 'business': + <li>${_('Billing Details')}</li> +%endif +</%block> -<%block name="content"> +<%block name="confirmation_highlight">class="active"</%block> +<%block name="custom_content"> <div class="container"> <section class="notification"> <h2>${_("Thank you for your Purchase!")}</h2> - <p>${_("Please print this receipt page for your records. You should also have received a receipt in your email.")}</p> - % for inst in instructions: - <p>${inst}</p> - % endfor + % if (len(shoppingcart_items) == 1 and order_type == 'personal') or receipt_has_donation_item: + % for inst in instructions: + <p>${inst}</p> + % endfor + % endif </section> + <section class="wrapper confirm-enrollment shopping-cart print"> + <div class="gray-bg"> + <div class="message-left"> + <% courses_url = reverse('courses') %> + % if order_type == 'personal': + ## in case of multiple courses in single self purchase scenario, + ## we will show the button View Dashboard + % if len(shoppingcart_items) > 1 : + <% dashboard_url = reverse('dashboard') %> + <a href="${dashboard_url}" class="blue pull-right">${_("View Dashboard")} <i class="icon-caret-right"></i></a> + % elif shoppingcart_items and shoppingcart_items[0][1]: + <% course = shoppingcart_items[0][1] %> + <% course_info_url = reverse('info', kwargs={'course_id': course.id.to_deprecated_string()}) %> + <a href="${course_info_url}" class="blue pull-right">${_("View Course")} <i class="icon-caret-right"></i></a> + %endif + ${_("You have successfully been enrolled for <b>{appended_course_names}</b>. The following receipt has been emailed to" + " <strong>{appended_recipient_emails}</strong>").format(appended_course_names=appended_course_names, appended_recipient_emails=appended_recipient_emails)} + % elif order_type == 'business': + % if total_registration_codes > 1 : + <% code_plural_form = 'codes' %> + % else: + <% code_plural_form = 'code' %> + % endif + ${_("You have successfully purchased <b>{total_registration_codes} course registration codes</b> " + "for <b>{appended_course_names}. </b>" + "The following receipt has been emailed to <strong>{appended_recipient_emails}</strong>" + ).format(total_registration_codes=total_registration_codes, appended_course_names=appended_course_names, appended_recipient_emails=appended_recipient_emails)} + % endif - <section class="wrapper cart-list"> - <div class="wrapper-content-main"> - <article class="content-main"> - <h1>${_("{platform_name} ({site_name}) Electronic Receipt").format(platform_name=microsite.get_value('platform_name', settings.PLATFORM_NAME), site_name=microsite.get_value('SITE_NAME', settings.SITE_NAME))}</h1> - <hr /> - - <table class="order-receipt"> - <tbody> - <tr> - <td colspan="2"><h3 class="order-number">${_("Order #")}${order.id}</h3></td> - <td></td> - <td colspan="2"><h3 class="order-date">${_("Date:")} ${order.purchase_time.date().isoformat()}</h3></td> - </tr> - <tr> - <td colspan="5"><h2 class="items-ordered">${_("Items ordered:")}</h2></td> - </tr> + </div> + </div> + % if order_type == 'business': + <h3 class="text-center">${_("Please send each professional one of these unique registration codes to enroll into the course. The confirmation/receipt email you will receive has an example email template with directions for the individuals enrolling.")}.</h3> + <table class="course-receipt"> + <thead> + <th>${_("Course Name")}</th> + <th>${_("Enrollment Code")}</th> + <th>${_("Enrollment Link")}</th> + </thead> + <tbody> + % for registration_code in registration_codes: + <% course = get_course_by_id(registration_code.course_id, depth=0) %> <tr> - <th class="qty">${_("Qty")}</th> - <th class="desc">${_("Description")}</th> - <th class="url">${_("URL")}</th> - <th class="u-pr">${_("Unit Price")}</th> - <th class="pri">${_("Price")}</th> - <th class="curr">${_("Currency")}</th> + <td>${_("{course_name}").format(course_name=course.display_name)}</td> + <td>${registration_code.code}</td> + + <% redemption_url = reverse('register_code_redemption', args = [registration_code.code] ) %> + <% enrollment_url = '{base_url}{redemption_url}'.format(base_url=site_name, redemption_url=redemption_url) %> + <td><a href="${redemption_url}">${enrollment_url}</a></td> </tr> - % for item in order_items: + % endfor + </tbody> + </table> + %endif + <div class="bordered-bar"> + <h2>${_('Invoice')} #${order.id}<span>${_('Date of purchase')}: ${order_purchase_date} </span><span + class="pull-right"><a href="" onclick="window.print();" class="blue-link"><i class="icon-print"></i> ${_('Print Receipt')}</a></span> + </h2> + </div> + % if order.total_cost > 0: + <div class="pattern"> + <h2> ${_("Billed To Details")}: </h2> + <div class="col-two no-border"> + % if order_type == 'business': + <div class="row"> + <div class="row-inside"> + <p> + <b>${_('Company Name')}:</b> + <label> + % if order.company_name: + ${_("{company_name}").format(company_name=order.company_name)} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Purchase Order Number')}:</b> + <label> + % if order.customer_reference_number: + ${_("{customer_reference_number}").format(customer_reference_number=order.customer_reference_number)} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Company Contact Name')}:</b> + <label> + % if order.company_contact_name: + ${_("{company_contact_name}").format(company_contact_name=order.company_contact_name)} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Company Contact Email')}:</b> + <label> + % if order.company_contact_email: + ${ order.company_contact_email } + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Recipient Name')}:</b> + <label> + % if order.recipient_name: + ${_("{recipient_name}").format(recipient_name=order.recipient_name)} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Recipient Email')}:</b> + <label> + % if order.recipient_email: + ${order.recipient_email} + % else: + N/A + % endif + </label> + </p> + </div> + </div> + %endif + <div class="row"> + <div class="row-inside"> + <p> + <b>${_('Card Type')}:</b> + <label> + % if order.bill_to_cardtype: + ${order.bill_to_cardtype} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Credit Card Number')}:</b> + <label> + % if order.bill_to_ccnum: + ${order.bill_to_ccnum} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Name')}:</b> + <label> + % if order.bill_to_first or order.bill_to_last: + ${order.bill_to_first} ${order.bill_to_last} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Address 1')}:</b> + <label> + % if order.bill_to_street1: + ${order.bill_to_street1} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Address 2')}:</b> + <label> + % if order.bill_to_street2: + ${order.bill_to_street2} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('City')}:</b> + <label> + % if order.bill_to_city: + ${order.bill_to_city} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('State')}:</b> + <label> + % if order.bill_to_state: + ${order.bill_to_state} + % else: + N/A + % endif + </label> + </p> + </div> + <div class="row-inside"> + <p> + <b>${_('Country')}:</b> + <label> + % if order.bill_to_country: + ${order.bill_to_country.upper()} + % else: + N/A + % endif + </label> + </p> + </div> + </div> + </div> + </div> + % endif + <hr class="border"/> + % for item, course in shoppingcart_items: + % if loop.index > 0 : + <hr> + %endif + <div class="user-data"> + <div class="clearfix"> + <div class="image"> + <img style="width: 100%; height: 100%;" src="${course_image_url(course)}" + alt="${course.display_number_with_default | h} ${get_course_about_section(course, 'title')} Image"/> + </div> + <div class="data-input"> + <h3>${_("Registration for")}: + <span class="pull-right"> + % if course.start_date_text or course.end_date_text: + ${_("Course Dates")}: + %endif + </span> + </h3> - <tr class="order-item"> + <h1>${_(" {course_name} ").format(course_name=course.display_name)} + <span class="pull-right"> + % if course.start_date_text: + ${course.start_date_text} + %endif + - + % if course.end_date_text: + ${course.end_date_text} + %endif + </span> + </h1> + <hr/> + <div class="three-col"> % if item.status == "purchased": - <td>${item.qty}</td> - <td>${item.line_desc}</td> - <td> - % if item.course_id: - <% course_id = reverse('info', args=[item.course_id.to_deprecated_string()]) %> - <a href="${course_id | h}" class="enter-course">${_('View Course')}</a></td> - % endif - </td> - <td>${"{0:0.2f}".format(item.unit_cost)} - % if item.list_price != None: - <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></tr> + <div class="col-1"> + % if item.list_price != None: + <div class="price">${_('Price per student:')} <span class="line-through"> $${"{0:0.2f}".format(item.list_price)}</span> + </div> + <div class="price green">${_('Discount Applied:')} <span> $${"{0:0.2f}".format(item.unit_cost)} </span></div> + % else: + <div class="price">${_('Price per student:')} <span> $${"{0:0.2f}".format(item.unit_cost)}</span></div> + % endif + </div> + <div class="col-2"> + <div class="numbers-row"> + <label>${_("Students")}:</label> + <div class="counter no-border text-dark-grey"> + ${item.qty} + </div> + </div> + </div> % elif item.status == "refunded": - <td><del>${item.qty}</del></td> - <td><del>${item.line_desc}</del></td> - <td><del>${"{0:0.2f}".format(item.unit_cost)}</del></td> - <td><del>${"{0:0.2f}".format(item.line_cost)}</del></td> - <td><del>${item.currency.upper()}</del></td></tr> - % endif - % endfor - <tr> - <td colspan="3"></td> - <th>${_("Total Amount")}</th> - <td></td> - </tr> - <tr> - <td colspan="3"></td> - <td>${"{0:0.2f}".format(order.total_cost)}</td> - <td></td> - </tr> - </tbody> - </table> - + <div class="col-1"> + % if item.list_price != None: + <div class="price">${_('Price per student:')} <span class="line-through"> $${"{0:0.2f}".format(item.list_price)}</span> + </div> + <div class="price green">${_('Discount Applied:')} <span><del> $${"{0:0.2f}".format(item.unit_cost)} + </del></span></div> + % else: + <div class="price">${_('Price per student:')} <span><del> $${"{0:0.2f}".format(item.unit_cost)}</del></span> + </div> + % endif + </div> + <div class="col-2"> + <div class="numbers-row"> + <label>${_("Students")}:</label> + <div class="counter no-border"> + <del>${item.qty}</del> + </div> + </div> + </div> + %endif + </div> + </div> + </div> + </div> + % endfor + <div class="discount"> + <div class="code-text"> % if any_refunds: - <p> - ## Translators: Please keep the "<del>" and "</del>" tags around your translation of the word "this" in your translation. + <span> + ## Translators: Please keep the "<del>" and "</del>" tags around your translation of the word "this" in your translation. ${_("Note: items with strikethough like <del>this</del> have been refunded.")} - </p> + </span> % endif - % 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> + <span class="pull-right">${_("Total")}: <b>$${"{0:0.2f}".format(order.total_cost)} USD</b></span> + </div> </div> </section> </div> diff --git a/lms/templates/shoppingcart/shopping_cart.html b/lms/templates/shoppingcart/shopping_cart.html new file mode 100644 index 0000000000000000000000000000000000000000..0bd92f388296d4bfcd8a512e94c73ead166c64af --- /dev/null +++ b/lms/templates/shoppingcart/shopping_cart.html @@ -0,0 +1,288 @@ +<%inherit file="shopping_cart_flow.html" /> +<%block name="review_highlight">class="active"</%block> + +<%! +from courseware.courses import course_image_url, get_course_about_section +from django.core.urlresolvers import reverse +from edxmako.shortcuts import marketing_link +from django.utils.translation import ugettext as _ + +%> + +<%block name="custom_content"> + +<div class="container"> + % if shoppingcart_items: + <%block name="billing_details_highlight"> + % if order.order_type == 'business': + <li>${_('Billing Details')}</li> + % endif + </%block> + <% discount_applied = False %> + <section class="wrapper confirm-enrollment shopping-cart"> + % for item, course in shoppingcart_items: + % if loop.index > 0 : + <hr> + %endif + <div class="user-data"> + <div class="clearfix"> + <div class="image"> + + <img style="width: 100%; height: 100%;" src="${course_image_url(course)}" + alt="${course.display_number_with_default | h} ${get_course_about_section(course, 'title')} Cover Image" /> + + </div> + <div class="data-input"> + <h3>${_('Registration for:')} <span class="pull-right">${_('Course Dates:')}</span></h3> + <h1>${ course.display_name }<span class="pull-right">${course.start_date_text} - ${course.end_date_text}</span></h1> + <hr /> + <div class="three-col"> + <div class="col-1"> + % if item.list_price != None: + <% discount_applied = True %> + <div class="price">${_('Price per student:')} <span class="line-through"> $${"{0:0.2f}".format(item.list_price)}</span></div> + <div class="price green">${_('Discount Applied:')} <span> $${"{0:0.2f}".format(item.unit_cost)} </span></div> + % else: + <div class="price">${_('Price per student:')} <span> $${"{0:0.2f}".format(item.unit_cost)}</span></div> + % endif + </div> + <div class="col-2"> + <div class="numbers-row"> + <label for="students">${_('Students:')}</label> + <div class="counter"> + <input maxlength="3" max="999" type="text" name="students" value="${item.qty}" id="${item.id}" > + </div> + <div class="inc button"><i class="icon-caret-up"><span>+</span></i></div><div class="dec button"><i class="icon-caret-down"></i></div> + <a name="updateBtn" class="updateBtn hidden" id="updateBtn-${item.id}" href="#">update</a> + <span class="error-text hidden" id="students-${item.id}"></span> + </div> + </div> + + <div class="col-3"> + <a href="#" class="btn-remove" data-item-id="${item.id}"><i class="icon-remove-sign"></i></a> + </div> + </div> + </div> + </div> + + </div> + % endfor + <div class="discount"> + + <div class="code-text"> + % if not discount_applied: + <div class="code-input"> + <input type="text" placeholder="discount or activation code" id="input_code"> + <input type="submit" value="Apply" class="blue" id="submit-code"> + <span class="error-text hidden" id="code" ></span> + </div> + % else: + <div class="code-applied"> + <span class="green"><i class="icon-ok"></i>${_('code has been applied')}</span> + <input type="submit" value="Reset" class="blue-border" id="submit-reset-redemption"> + </div> + %endif + <span class="pull-right">${_('Total:')} <b id="total-amount">$${"{0:0.2f}".format(amount)} USD</b></span> + </div> + </div> + <div class="col-two"> + <div class="col-2 relative"> + % if amount == 0: + <input type="submit" value = "Register" id="register" > + % elif item.order.order_type == 'business': + <input type="submit" value = "Billing Details" id="billing-details"><i class="icon-caret-right"></i> + <p> + ${_('After this purchase is complete, a receipt is generated with relative billing details and registration codes for students.')} + </p> + % else: + ${form_html} + <p> + ${_('After this purchase is complete,')}<br/><b>${order.user.username}</b> + ${_('will be enrolled in this course.')} + </p> + %endif + </div> + </div> + </section> + % else: + <div class="empty-cart" > + <h2>${_('Your Shopping cart is currently empty.')}</h2> + <a href="${marketing_link('COURSES')}" class="blue">${_('View Courses')}</a> + </div> + % endif + +</div> +</%block> +<script> + $(function() { + + $('a.btn-remove').click(function(event) { + event.preventDefault(); + var post_url = "${reverse('shoppingcart.views.remove_item')}"; + $.post(post_url, {id:$(this).data('item-id')}) + .always(function(data){ + location.reload(true); + }); + }); + + $('#submit-code').click(function(event){ + event.preventDefault(); + var post_url = "${reverse('shoppingcart.views.use_code')}"; + if($('#input_code').val() == "") { + showErrorMsgs('Must enter a valid code','code'); + return; + } + $.post(post_url,{ + "code" : $('#input_code').val() + } + ) + .success(function(data) { + location.reload(true); + }) + .error(function(data,status) { + if(status=="parsererror"){ + location.reload(true); + }else{ + showErrorMsgs(data.responseText, 'code') + } + }) + }); + + $('#submit-reset-redemption').click(function(event){ + event.preventDefault(); + var post_url = "${reverse('shoppingcart.views.reset_code_redemption')}"; + $.post(post_url) + .success(function(data) { + location.reload(true); + }) + .error(function(data,status) { + if(status=="parsererror"){ + location.reload(true); + }else{ + showErrorMsgs(data.responseText,'code') + } + }) + }); + + $('#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) + } + }) + }); + + $('#billing-details').click(function(event){ + event.preventDefault(); + location.href = "${reverse('shoppingcart.views.billing_details')}"; + }); + + + $(".button").on("click", function() { + var studentField = $(this).parent().find('input'); + var ItemId = studentField.attr('id'); + var $button = $(this); + var oldValue = $button.parent().find("input").val(); + var newVal = 1; // initialize with 1. + hideErrorMsg('students-'+ItemId); + if ($.isNumeric(oldValue)){ + if ($button.text() == "+") { + if(oldValue > 0){ + newVal = parseFloat(oldValue) + 1; + if(newVal > 1000){ + newVal = 1000; + } + } + } else { + // Don't allow decrementing below one + if (oldValue > 1) { + newVal = parseFloat(oldValue) - 1; + } + } + } + $button.parent().find("input").val(newVal); + $('#updateBtn-'+ItemId).removeClass('hidden'); + + }); + +$('a[name="updateBtn"]').click(function(event) { + var studentField = $(this).parent().find('input'); + var number_of_students = studentField.val(); + var ItemId = studentField.attr('id'); + + if($.isNumeric(number_of_students) && number_of_students > 0 ){ + hideErrorMsg('students-'+ItemId); + update_user_cart(ItemId, number_of_students); + }else{ + showErrorMsgs('quantity must be greater then 0.', 'students-'+ItemId); + } +}); + + function showErrorMsgs(msg, msg_area){ + + $( "span.error-text#"+ msg_area +"" ).removeClass("hidden"); + $( "span.error-text#"+ msg_area +"" ).html(msg).show(); + + if(msg_area=='code'){ + $("#input_code").addClass('error'); + } + } + + function hideErrorMsg(msg_area){ + $( "span.error-text#"+ msg_area +"" ).addClass("hidden"); + } + + function update_user_cart(ItemId, number_of_students){ + var post_url = "${reverse('shoppingcart.views.update_user_cart')}"; + $.post(post_url, { + ItemId:ItemId, + qty:number_of_students + } + ) + .success(function(data) { + location.reload(true); + }) + .error(function(data,status) { + location.reload(true); + }) + } + +$('input[name="students"]').on("click", function() { + $('#updateBtn-'+this.id).removeClass('hidden'); +}); + +// allowing user to enter numeric qty only. + $("input[name=students]").keydown(function(event) { + var eventDelete = 46; + var eventBackSpace = 8; + var eventLeftKey = 37; + var eventRightKey = 39; + var allowedEventCodes = [eventDelete, eventBackSpace, eventLeftKey, eventRightKey ]; + // Allow only backspace and delete + if (allowedEventCodes.indexOf(event.keyCode) > -1) { + // let it happen, don't do anything + } + else { + /* + Ensure that it is a number. + KeyCode range 48 - 57 represents [0-9] + KeyCode range 96 - 105 represents [numpad 0 - numpad 9] + */ + if ((event.keyCode >= 48 && event.keyCode <= 57) || (event.keyCode >= 96 && event.keyCode <= 105) ) { + $('#updateBtn-'+this.id).removeClass('hidden'); + }else{ + event.preventDefault(); + } + } + + }); +}); +</script> diff --git a/lms/templates/shoppingcart/shopping_cart_flow.html b/lms/templates/shoppingcart/shopping_cart_flow.html new file mode 100644 index 0000000000000000000000000000000000000000..bc7fa4731714a4978ed560eecf06bd0398f6c3a5 --- /dev/null +++ b/lms/templates/shoppingcart/shopping_cart_flow.html @@ -0,0 +1,27 @@ +<%! +from django.utils.translation import ugettext as _ +%> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> +<%block name="pagetitle">${_("Shopping cart")}</%block> + + + +<%block name="bodyextra"> + +<div class="container"> + <section class="wrapper confirm-enrollment shopping-cart"> + <h1> ${_("{site_name} - Shopping Cart").format(site_name=site_name)}</h1> + % if shoppingcart_items: + <ul class="steps"> + <li <%block name="review_highlight"/>>${_('Review')}</li> + <%block name="billing_details_highlight"/> + <li <%block name="payment_highlight"/>>${_('Payment')}</li> + <li <%block name="confirmation_highlight"/>>${_('Confirmation')}</li> + </ul> + %endif + </section> +</div> +<%block name="custom_content"/> + +</%block> diff --git a/lms/templates/shoppingcart/verified_cert_receipt.html b/lms/templates/shoppingcart/verified_cert_receipt.html index 85b36aa231ccb12ad63abebccf2483a4503b4fcf..885694c241934d5b0fa35ce08a99b2da68cf1973 100644 --- a/lms/templates/shoppingcart/verified_cert_receipt.html +++ b/lms/templates/shoppingcart/verified_cert_receipt.html @@ -103,7 +103,7 @@ </thead> <tbody> - % for item in order_items: + % for item, course in shoppingcart_items: <tr> <td>${item.line_desc}</td> <td> @@ -158,7 +158,7 @@ </thead> <tbody> - % for item in order_items: + % for item, course in shoppingcart_items: <tr> % if item.status == "purchased": <td>${order.id}</td>