From 5a9fc036a679592bbbba9d204862d4935c72e3c1 Mon Sep 17 00:00:00 2001 From: asadiqbal08 Date: Thu, 17 Jul 2014 14:31:04 +0500 Subject: [PATCH] Course Instructor as financeadmin role, will be able to download all purchase transactions in the course as a CSV file --- lms/djangoapps/instructor/tests/test_api.py | 80 +++++++++++++++++ .../instructor/tests/test_ecommerce.py | 2 + lms/djangoapps/instructor/views/api.py | 28 ++++++ lms/djangoapps/instructor/views/api_urls.py | 2 + .../instructor/views/instructor_dashboard.py | 1 + lms/djangoapps/instructor_analytics/basic.py | 53 +++++++++++- .../instructor_dashboard/e-commerce.coffee | 36 ++++++++ .../instructor_dashboard.coffee | 3 + .../sass/course/instructor/_instructor_2.scss | 86 ++++++++++++++++++- .../instructor_dashboard_2/e-commerce.html | 6 ++ 10 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 lms/static/coffee/src/instructor_dashboard/e-commerce.coffee diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index f34984ee6b..df5741d479 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -44,6 +44,8 @@ import instructor.views.api from instructor.views.api import _split_input_list, common_exceptions_400 from instructor_task.api_helper import AlreadyRunningError from opaque_keys.edx.locations import SlashSeparatedCourseKey +from shoppingcart.models import Order, PaidCourseRegistration, Coupon +from course_modes.models import CourseMode from .test_tools import msk_from_problem_urlname, get_extended_due @@ -1330,13 +1332,91 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa """ def setUp(self): self.course = CourseFactory.create() + self.course_mode = CourseMode(course_id=self.course.id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=40) + self.course_mode.save() self.instructor = InstructorFactory(course_key=self.course.id) self.client.login(username=self.instructor.username, password='test') + self.cart = Order.get_cart_for_user(self.instructor) + self.coupon_code = 'abcde' + self.coupon = Coupon(code=self.coupon_code, description='testing code', course_id=self.course.id, + percentage_discount=10, created_by=self.instructor, is_active=True) + self.coupon.save() self.students = [UserFactory() for _ in xrange(6)] for student in self.students: CourseEnrollment.enroll(student, self.course.id) + def test_get_ecommerce_purchase_features_csv(self): + """ + Test that the response from get_purchase_transaction is in csv format. + """ + PaidCourseRegistration.add_to_order(self.cart, self.course.id) + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + url = reverse('get_purchase_transaction', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url + '/csv', {}) + self.assertEqual(response['Content-Type'], 'text/csv') + + def test_get_ecommerce_purchase_features_with_coupon_info(self): + """ + Test that some minimum of information is formatted + correctly in the response to get_purchase_transaction. + """ + PaidCourseRegistration.add_to_order(self.cart, self.course.id) + url = reverse('get_purchase_transaction', kwargs={'course_id': self.course.id.to_deprecated_string()}) + + # using coupon code + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + response = self.client.get(url, {}) + res_json = json.loads(response.content) + self.assertIn('students', res_json) + + for res in res_json['students']: + self.validate_purchased_transaction_response(res, self.cart, self.instructor, self.coupon_code) + + def test_get_ecommerce_purchases_features_without_coupon_info(self): + """ + Test that some minimum of information is formatted + correctly in the response to get_purchase_transaction. + """ + url = reverse('get_purchase_transaction', kwargs={'course_id': self.course.id.to_deprecated_string()}) + + carts, instructors = ([] for i in range(2)) + + # purchasing the course by different users + for _ in xrange(3): + test_instructor = InstructorFactory(course_key=self.course.id) + self.client.login(username=test_instructor.username, password='test') + cart = Order.get_cart_for_user(test_instructor) + carts.append(cart) + instructors.append(test_instructor) + PaidCourseRegistration.add_to_order(cart, self.course.id) + cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + + response = self.client.get(url, {}) + res_json = json.loads(response.content) + self.assertIn('students', res_json) + for res, i in zip(res_json['students'], xrange(3)): + self.validate_purchased_transaction_response(res, carts[i], instructors[i], 'None') + + def validate_purchased_transaction_response(self, res, cart, user, code): + """ + validate purchased transactions attribute values with the response object + """ + item = cart.orderitem_set.all().select_subclasses()[0] + + self.assertEqual(res['coupon_code'], code) + self.assertEqual(res['username'], user.username) + self.assertEqual(res['email'], user.email) + self.assertEqual(res['list_price'], item.list_price) + self.assertEqual(res['unit_cost'], item.unit_cost) + self.assertEqual(res['order_id'], cart.id) + self.assertEqual(res['orderitem_id'], item.id) + def test_get_students_features(self): """ Test that some minimum of information is formatted diff --git a/lms/djangoapps/instructor/tests/test_ecommerce.py b/lms/djangoapps/instructor/tests/test_ecommerce.py index dcfcc253b8..34a8f3c0d9 100644 --- a/lms/djangoapps/instructor/tests/test_ecommerce.py +++ b/lms/djangoapps/instructor/tests/test_ecommerce.py @@ -59,6 +59,7 @@ class TestECommerceDashboardViews(ModuleStoreTestCase): # Total amount html should render in e-commerce page, total amount will be 0 total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course.id) self.assertTrue('Total Amount: $' + str(total_amount) + '' in response.content) + self.assertTrue('Download All e-Commerce Purchase' in response.content) # removing the course finance_admin role of login user CourseFinanceAdminRole(self.course.id).remove_users(self.instructor) @@ -67,6 +68,7 @@ class TestECommerceDashboardViews(ModuleStoreTestCase): url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()}) response = self.client.post(url) total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course.id) + self.assertFalse('Download All e-Commerce Purchase' in response.content) self.assertFalse('Total Amount: $' + str(total_amount) + '' in response.content) def test_add_coupon(self): diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index ab8bcecc5a..365c98ddde 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -547,6 +547,34 @@ def get_grading_config(request, course_id): return JsonResponse(response_payload) +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def get_purchase_transaction(request, course_id, csv=False): # pylint: disable=W0613, W0621 + """ + return the summary of all purchased transactions for a particular course + """ + course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + query_features = [ + 'id', 'username', 'email', 'course_id', 'list_price', 'coupon_code', + 'unit_cost', 'purchase_time', 'orderitem_id', + 'order_id', + ] + + student_data = analytics.basic.purchase_transactions(course_id, query_features) + + if not csv: + response_payload = { + 'course_id': course_id.to_deprecated_string(), + 'students': student_data, + 'queried_features': query_features + } + return JsonResponse(response_payload) + else: + header, datarows = analytics.csvs.format_dictlist(student_data, query_features) + return analytics.csvs.create_csv_response("e-commerce_purchase_transactions.csv", header, datarows) + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index e20f2988a7..77ebf3d479 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -17,6 +17,8 @@ urlpatterns = patterns('', # nopep8 'instructor.views.api.get_grading_config', name="get_grading_config"), url(r'^get_students_features(?P/csv)?$', 'instructor.views.api.get_students_features', name="get_students_features"), + url(r'^get_purchase_transaction(?P/csv)?$', + 'instructor.views.api.get_purchase_transaction', name="get_purchase_transaction"), url(r'^get_anon_ids$', 'instructor.views.api.get_anon_ids', name="get_anon_ids"), url(r'^get_distribution$', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index d9118d3016..2590fc373a 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -142,6 +142,7 @@ def _section_e_commerce(course_key, access): 'ajax_get_coupon_info': reverse('get_coupon_info', kwargs={'course_id': course_key.to_deprecated_string()}), 'ajax_update_coupon': reverse('update_coupon', kwargs={'course_id': course_key.to_deprecated_string()}), 'ajax_add_coupon': reverse('add_coupon', kwargs={'course_id': course_key.to_deprecated_string()}), + 'get_purchase_transaction_url': reverse('get_purchase_transaction', kwargs={'course_id': course_key.to_deprecated_string()}), 'instructor_url': reverse('instructor_dashboard', kwargs={'course_id': course_key.to_deprecated_string()}), 'coupons': coupons, 'total_amount': total_amount, diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 0ec5876014..2254cdfc3a 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -3,7 +3,7 @@ Student and course analytics. Serve miscellaneous course and student data """ - +from shoppingcart.models import PaidCourseRegistration, CouponRedemption from django.contrib.auth.models import User import xmodule.graders as xmgraders @@ -11,9 +11,60 @@ import xmodule.graders as xmgraders STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email') PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals') +ORDER_ITEM_FEATURES = ('list_price', 'unit_cost', 'order_id') +ORDER_FEATURES = ('purchase_time',) + AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES +def purchase_transactions(course_id, features): + """ + Return list of purchased transactions features as dictionaries. + + purchase_transactions(course_id, ['username, email', unit_cost]) + would return [ + {'username': 'username1', 'email': 'email1', unit_cost:'cost1 in decimal'.} + {'username': 'username2', 'email': 'email2', unit_cost:'cost2 in decimal'.} + {'username': 'username3', 'email': 'email3', unit_cost:'cost3 in decimal'.} + ] + """ + + purchased_courses = PaidCourseRegistration.objects.filter(course_id=course_id, status='purchased') + + def purchase_transactions_info(purchased_course, features): + """ convert purchase transactions to dictionary """ + coupon_code_dict = dict() + student_features = [x for x in STUDENT_FEATURES if x in features] + order_features = [x for x in ORDER_FEATURES if x in features] + order_item_features = [x for x in ORDER_ITEM_FEATURES if x in features] + + # Extracting user information + student_dict = dict((feature, getattr(purchased_course.user, feature)) + for feature in student_features) + + # Extracting Order information + order_dict = dict((feature, getattr(purchased_course.order, feature)) + for feature in order_features) + + # Extracting OrderItem information + order_item_dict = dict((feature, getattr(purchased_course, feature)) + for feature in order_item_features) + order_item_dict.update({"orderitem_id": getattr(purchased_course, 'id')}) + + try: + coupon_redemption = CouponRedemption.objects.select_related('coupon').get(order_id=purchased_course.order_id) + except CouponRedemption.DoesNotExist: + coupon_code_dict = {'coupon_code': 'None'} + else: + coupon_code_dict = {'coupon_code': coupon_redemption.coupon.code} + + student_dict.update(dict(order_dict.items() + order_item_dict.items() + coupon_code_dict.items())) + student_dict.update({'course_id': course_id.to_deprecated_string()}) + return student_dict + + return [purchase_transactions_info(purchased_course, features) for purchased_course in purchased_courses] + + def enrolled_students_features(course_id, features): """ Return list of student features as dictionaries. diff --git a/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee b/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee new file mode 100644 index 0000000000..24449f771a --- /dev/null +++ b/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee @@ -0,0 +1,36 @@ +### +E-Commerce Download Section +### + +# Ecommerce Purchase Download Section +class ECommerce + constructor: (@$section) -> + # attach self to html so that instructor_dashboard.coffee can find + # this object to call event handlers like 'onClickTitle' + @$section.data 'wrapper', @ + + # gather elements + @$list_purchase_csv_btn = @$section.find("input[name='list-purchase-transaction-csv']'") + + # attach click handlers + # this handler binds to both the download + # and the csv button + @$list_purchase_csv_btn.click (e) => + url = @$list_purchase_csv_btn.data 'endpoint' + url += '/csv' + location.href = url + + + # handler for when the section title is clicked. + onClickTitle: -> + @clear_display() + + clear_display: -> + + +# export for use +# create parent namespaces if they do not already exist. +_.defaults window, InstructorDashboard: {} +_.defaults window.InstructorDashboard, sections: {} +_.defaults window.InstructorDashboard.sections, + ECommerce: ECommerce diff --git a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee index dec365f6ae..fa7c0d353f 100644 --- a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee @@ -155,6 +155,9 @@ setup_instructor_dashboard_sections = (idash_content) -> , constructor: window.InstructorDashboard.sections.DataDownload $element: idash_content.find ".#{CSS_IDASH_SECTION}#data_download" + , + constructor: window.InstructorDashboard.sections.ECommerce + $element: idash_content.find ".#{CSS_IDASH_SECTION}#e-commerce" , constructor: window.InstructorDashboard.sections.Membership $element: idash_content.find ".#{CSS_IDASH_SECTION}#membership" diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index d1b1a70d78..3a0efe76f0 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -931,7 +931,7 @@ input[name="subject"] { } } - // coupon edit and add modals + // coupon edit and add modals #add-coupon-modal, #edit-coupon-modal{ .inner-wrapper { background: #fff; @@ -1046,4 +1046,88 @@ input[name="subject"] { } } } + +} + +.profile-distribution-widget { + margin-bottom: $baseline * 2; + + .display-text {} + + .display-graph .graph-placeholder { + width: 750px; + height: 250px; + } + + .display-table { + .slickgrid { + height: 250px; + } + } +} + +.grade-distributions-widget { + margin-bottom: $baseline * 2; + + .last-updated { + line-height: 2.2em; + @include font-size(12); + } + + .display-graph .graph-placeholder { + width: 750px; + height: 200px; + } + + .display-text { + line-height: 2em; + } +} + +input[name="subject"] { + width:600px; +} + +.enrollment-wrapper { + margin-bottom: $baseline * 2; + + .count { + color: green; + font-weight: bold; + } +} + +.ecommerce-wrapper{ + h2{ + height: 26px; + line-height: 26px; + span{ + float: right; + font-size: 16px; + font-weight: bold; + span{ + background: #ddd; + padding: 2px 9px; + border-radius: 2px; + float: none; + font-weight: 400; + } + } + } + span.tip{ + padding: 10px 15px; + display: block; + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + background: #f8f4ec; + color: #3c3c3c; + line-height: 30px; + .add{ + @include button(simple, $blue); + @extend .button-reset; + font-size: em(13); + float: right; + } + } + } diff --git a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html index 061ca5dcac..5bc7d9cee9 100644 --- a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html +++ b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html @@ -3,6 +3,12 @@ <%include file="add_coupon_modal.html" args="section_data=section_data" /> <%include file="edit_coupon_modal.html" args="section_data=section_data" /> +%if section_data['access']['finance_admin'] is True: +

${_("Click to generate a CSV file for all purchase transactions in this course")}

+ +

+%endif +

${_("Coupons List")} %if section_data['total_amount'] is not None: