From 4f7c494922ec85ca73beddb38f47b9bb41ea69ba Mon Sep 17 00:00:00 2001 From: asadiqbal08 Date: Fri, 12 Sep 2014 19:45:42 +0500 Subject: [PATCH] EX-71 New shopping Cart UI Single person purchase Ex-74 Registration Code redemption fix the translation issues added a check if a user is already registered in a course. Changed the messages added course depth=0 and removed pep8 violations Ex-72-added additional billing information Added a new CSV file in the instructor dashboard sales tab to download all the order sales separated from the invoice sales fix path to image updated the failed unit tests and add some minor tweaks in the Shoppingcart.scss Ex-78 New UI In receipt page EX-73 Purchasers can buy a course on behalf of a different student WL-78 updated the receipt page UI. Wl-72 updated Billing Information UI and removed the Order Address fields WL-71 Remove Purchase Type Buttons from UI WL-71 Updated minor UI issues and updated test cases WL-78 updated the enrollment links in the receipt page made changes in Order generated sales csv in Instructor Dashboard. The total_registration_codes and total_used_codes were not correctly stored in the csv file. 1) The total_registration_codes were not filtered with course_id. 2) The total_used_codes that a user had redeemed were not correctly included in the CSV. added a fix in the courseware view to let the users visit the courseware if they have enrolled in the course by clicking on the enrollment link rebase and resolved conflicts with master WL-97 Bulk Registration Email Confirmation Below is the commit summary. - Make email text bold as per requirement. - Improve email template quality and reorder points. - Add text in billing details page : "if no additional billing details are populated the payment confirmation will be sent to the user making the purchase" - Update text on receipt page "You have successfully purchase 3 course registration codes" WL-100 fixed the bug on the edit/add coupon and set course price. Ajax requests were duplicating in each callback. fixed this issue by creating the manual ajax request rather than the Lean Modal Ajax requests allow for better White Label branding in shopping cart purchase emails fix up typos and text fix goof fix fix incorporated model changes as suggested by Jason. updated order sales csv updated test cases for CourseRegCodeItem model and csv for the order generated sales updated the migrations history fixed the lms acceptance tests Be sure to check for multiple types address PR feedback PR feedback PR feedback pep8 fix --- common/djangoapps/student/views.py | 18 +- lms/djangoapps/courseware/views.py | 27 +- lms/djangoapps/instructor/tests/test_api.py | 15 + lms/djangoapps/instructor/views/api.py | 63 +- lms/djangoapps/instructor/views/api_urls.py | 2 + lms/djangoapps/instructor/views/coupons.py | 33 +- .../instructor/views/instructor_dashboard.py | 16 +- lms/djangoapps/instructor_analytics/basic.py | 73 +- .../instructor_analytics/tests/test_basic.py | 57 +- .../shoppingcart/context_processor.py | 2 +- ...d_courseregcodeitemannotation__add_fiel.py | 284 ++++++++ lms/djangoapps/shoppingcart/models.py | 390 +++++++++- .../shoppingcart/tests/test_models.py | 60 +- .../shoppingcart/tests/test_reports.py | 11 +- .../shoppingcart/tests/test_views.py | 246 ++++++- lms/djangoapps/shoppingcart/urls.py | 3 + lms/djangoapps/shoppingcart/views.py | 212 +++++- .../instructor_dashboard/e-commerce.coffee | 5 + lms/static/sass/base/_base.scss | 9 + lms/static/sass/base/_variables.scss | 11 + .../sass/course/instructor/_instructor_2.scss | 19 +- lms/static/sass/views/_shoppingcart.scss | 669 +++++++++++++++++- .../business_order_confirmation_email.txt | 93 +++ .../emails/order_confirmation_email.txt | 14 +- .../add_coupon_modal.html | 6 +- .../instructor_dashboard_2/e-commerce.html | 102 ++- .../edit_coupon_modal.html | 2 +- .../set_course_mode_price_modal.html | 4 +- .../shoppingcart/billing_details.html | 114 +++ .../shoppingcart/cybersource_form.html | 2 +- lms/templates/shoppingcart/list.html | 129 ---- lms/templates/shoppingcart/receipt.html | 438 +++++++++--- lms/templates/shoppingcart/shopping_cart.html | 288 ++++++++ .../shoppingcart/shopping_cart_flow.html | 27 + .../shoppingcart/verified_cert_receipt.html | 4 +- 35 files changed, 3029 insertions(+), 419 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0020_auto__add_courseregcodeitem__add_courseregcodeitemannotation__add_fiel.py create mode 100644 lms/templates/emails/business_order_confirmation_email.txt create mode 100644 lms/templates/shoppingcart/billing_details.html delete mode 100644 lms/templates/shoppingcart/list.html create mode 100644 lms/templates/shoppingcart/shopping_cart.html create mode 100644 lms/templates/shoppingcart/shopping_cart_flow.html diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 14be87b300..94a81590e4 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 f92326690a..520c2f7db4 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 480622672b..94c980fa5a 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 c395823a14..7b92a13516 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 3bf9ba651f..4968f1b104 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)?$', '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 48f9828863..c4ec58d9d2 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 af1959f209..12e01a32c3 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 eecede860a..f26ccde17a 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 788c55fb12..ad735de250 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 540ab6a2f3..df667569d0 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 0000000000..ce99fdb642 --- /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 6bcab0d280..2b27dcc252 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 14f2369a4d..d2d992b89f 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 e3f69dee9c..80402d3300 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 fae0285673..5c1c16ddb9 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 {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 6d5865f93d..02776da0a0 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[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 869ebea6a0..53c72219ab 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,21 +574,20 @@ 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 - context = { - 'order': order, - 'order_items': order_items, - 'any_refunds': any_refunds, - 'instructions': instructions, - } - - if order_items.count() == 1: - receipt_template = order_items[0].single_item_receipt_template - context.update(order_items[0].single_item_receipt_context) + 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) @@ -487,6 +596,39 @@ def show_receipt(request, ordernum): 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, + '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) + 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 2f614e6cd4..026e321b5b 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 8f6cfe98a6..4a9fbcc1fc 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 a54a4687c7..91cb01dfdf 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 6bc0618bdd..8aebd745be 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 1b5119c9a1..65dcd8c00d 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 0000000000..870ca26c98 --- /dev/null +++ b/lms/templates/emails/business_order_confirmation_email.txt @@ -0,0 +1,93 @@ +<%! from django.utils.translation import ugettext as _ %> +

+${_("Hi {name},").format(name=recipient_name)} +

+

+${_("Thank you for your purchase of ")} ${course_names} +

+%if recipient_type == 'user': +

${_("Your payment was successful.")}

+% if 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=payment_support_email)}

+% endif + +%elif recipient_type == 'company_contact': + +

${_("{order_placed_by} placed an order and mentioned your name as the Organization contact.").format(order_placed_by=order_placed_by)}

+ +%elif recipient_type == 'email_recipient': + +

${_("{order_placed_by} placed an order and mentioned your name as the additional receipt recipient.").format(order_placed_by=order_placed_by)}

+ +%endif + +

${_("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}

+%endfor + +

${_("Total billed to credit/debit card: {currency_symbol}{total_cost}").format(total_cost=order.total_cost, currency_symbol=("$" if order.currency == 'usd' else ""))}

+ +

+% if order.company_name: +${_('Company Name:')} ${order.company_name}
+%endif +% if order.customer_reference_number: +${_('Purchase Order Number:')} ${order.customer_reference_number}
+%endif +% if order.company_contact_name: +${_('Company Contact Name:')} ${order.company_contact_name}
+ %endif +% if order.company_contact_email: +${_('Company Contact Email:')} ${order.company_contact_email}
+%endif +% if order.recipient_name: +${_('Recipient Name:')} ${order.recipient_name}
+ %endif +% if order.recipient_email: +${_('Recipient Email:')} ${order.recipient_email}
+ %endif + +% if has_billing_info: +${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}
+${order.bill_to_first} ${order.bill_to_last}
+${order.bill_to_street1}
+${order.bill_to_street2}
+${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}
+${order.bill_to_country.upper()} +% endif +

+

${_("Order Number: {order_number}").format(order_number=order.id)}

+ +

${_("A CSV file of your registration URLs is attached. Please distribute registration URLs to each student planning to enroll using the email template below.")}

+ +

${_("Warm regards,")}
+% if payment_email_signature: +${payment_email_signature} +% else: +${_("The {platform_name} Team").format(platform_name=platform_name)} +%endif +

+ + +——————————————————————————————————————————— + + +

Dear [[Name]]

+ +

To enroll in ${course_names} we have provided a registration URL for you. Please follow the instructions below to claim your access.

+ +

Your redeem url is: [[Enter Redeem URL here from the attached CSV]]

+ +

${_("(1) Register for an account at https://{site_name}.").format(site_name=site_name)}
+${_("(2) Once registered, copy the redeem URL and paste it in your web browser.")}
+${_("(3) On the enrollment confirmation page, Click the 'Activate Enrollment Code' button. This will show the enrollment confirmation.")}
+${_("(4) You should be able to click on 'view course' button or see your course on your student dashboard at https://{dashboard_url}").format(dashboard_url=dashboard_url)}
+${_("(5) Course materials will not be available until the course start date.")}

+ +

Sincerely,

+

[[Your Signature]]

diff --git a/lms/templates/emails/order_confirmation_email.txt b/lms/templates/emails/order_confirmation_email.txt index 65c0e3a67e..87d4c26b80 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 82990bd8e5..04b8c14806 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"/> -