From b555c869bfbaf5775966bd39de18eef83dc38e85 Mon Sep 17 00:00:00 2001 From: Muhammad Shoaib Date: Thu, 7 May 2015 19:52:37 +0500 Subject: [PATCH] SOL-794 Detailed Enrollment Report - added the abstract and concrete layers of enrollment report provider - created a celery task. -added the button in the e-commerce reports section added the enrollment data backend added the payment data and start writing the test cases. updated the code with the feedback suggestions and wrote some test cases. - all the downloadable reports are now visible in the ecommerce download section. Pending instructor tasks is also visible in the ecommerce section added the fields in the user profile information changed the report store configuration key added the new http endpoint for financial reports to add more permissions for finance_admin to access. fix quality issues added test cases to check csv content data rebased with master and resolved conflicts changed the log messages added the changes as per code clintonb suggestions during code review updated the test cases for the finance_admin decorator changes suggested by clinton. Created and moved Table level filters to the Custom Manager for the CourseEnrollment model. ecommerce.js file was loaded twice in the instructor_dashboard.js fixed the issues added the registration code column in the csv added the full gender in the csv file Update data sources and add display name translations for the report columns fix meta name Make sure the reports section does not appear on non whitelabel courses pylint fixes expand out enumerated values --- common/djangoapps/student/models.py | 110 +++++---- lms/djangoapps/courseware/views.py | 2 +- .../instructor/enrollment_report.py | 98 ++++++++ .../management/commands/openended_stats.py | 2 +- .../paidcourse_enrollment_report.py | 178 ++++++++++++++ lms/djangoapps/instructor/tests/test_api.py | 217 +++++++++++++++++- .../tests/test_enrollment_store_provider.py | 75 ++++++ lms/djangoapps/instructor/views/api.py | 72 +++++- lms/djangoapps/instructor/views/api_urls.py | 9 + .../instructor/views/instructor_dashboard.py | 13 +- lms/djangoapps/instructor/views/legacy.py | 2 +- lms/djangoapps/instructor_task/api.py | 16 +- lms/djangoapps/instructor_task/models.py | 18 +- lms/djangoapps/instructor_task/tasks.py | 16 +- .../instructor_task/tasks_helper.py | 135 ++++++++++- .../instructor_task/tests/test_api.py | 7 +- .../instructor_task/tests/test_base.py | 2 +- .../instructor_task/tests/test_models.py | 4 +- .../tests/test_tasks_helper.py | 187 ++++++++++++++- lms/djangoapps/shoppingcart/models.py | 35 +++ lms/djangoapps/shoppingcart/reports.py | 2 +- lms/envs/aws.py | 3 + lms/envs/common.py | 6 + .../instructor_dashboard/data_download.coffee | 63 +---- .../instructor_dashboard/e-commerce.coffee | 23 +- .../src/instructor_dashboard/util.coffee | 61 +++++ .../js/instructor_dashboard/ecommerce.js | 25 +- .../sass/course/instructor/_instructor_2.scss | 19 +- .../instructor_dashboard_2/e-commerce.html | 41 +++- .../instructor_dashboard_2.html | 1 - 30 files changed, 1284 insertions(+), 158 deletions(-) create mode 100644 lms/djangoapps/instructor/enrollment_report.py create mode 100644 lms/djangoapps/instructor/paidcourse_enrollment_report.py create mode 100644 lms/djangoapps/instructor/tests/test_enrollment_store_provider.py diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index a783b60a48..647a636d5a 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -736,6 +736,66 @@ class AlreadyEnrolledError(CourseEnrollmentException): pass +class CourseEnrollmentManager(models.Manager): + """ + Custom manager for CourseEnrollment with Table-level filter methods. + """ + + def num_enrolled_in(self, course_id): + """ + Returns the count of active enrollments in a course. + + 'course_id' is the course_id to return enrollments + """ + + enrollment_number = super(CourseEnrollmentManager, self).get_query_set().filter( + course_id=course_id, + is_active=1 + ).count() + + return enrollment_number + + def is_course_full(self, course): + """ + Returns a boolean value regarding whether a course has already reached it's max enrollment + capacity + """ + is_course_full = False + if course.max_student_enrollments_allowed is not None: + is_course_full = self.num_enrolled_in(course.id) >= course.max_student_enrollments_allowed + return is_course_full + + def users_enrolled_in(self, course_id): + """Return a queryset of User for every user enrolled in the course.""" + return User.objects.filter( + courseenrollment__course_id=course_id, + courseenrollment__is_active=True + ) + + def enrollment_counts(self, course_id): + """ + Returns a dictionary that stores the total enrollment count for a course, as well as the + enrollment count for each individual mode. + """ + # Unfortunately, Django's "group by"-style queries look super-awkward + query = use_read_replica_if_available( + super(CourseEnrollmentManager, self).get_query_set().filter(course_id=course_id, is_active=True).values( + 'mode').order_by().annotate(Count('mode'))) + total = 0 + enroll_dict = defaultdict(int) + for item in query: + enroll_dict[item['mode']] = item['mode__count'] + total += item['mode__count'] + enroll_dict['total'] = total + return enroll_dict + + def enrolled_and_dropped_out_users(self, course_id): + """Return a queryset of Users in the course.""" + return User.objects.filter( + courseenrollment__course_id=course_id + ) + + class CourseEnrollment(models.Model): """ Represents a Student's Enrollment record for a single Course. You should @@ -762,6 +822,8 @@ class CourseEnrollment(models.Model): # list of possible values. mode = models.CharField(default="honor", max_length=100) + objects = CourseEnrollmentManager() + class Meta: unique_together = (('user', 'course_id'),) ordering = ('user', 'course_id') @@ -831,17 +893,6 @@ class CourseEnrollment(models.Model): except cls.DoesNotExist: return None - @classmethod - def num_enrolled_in(cls, course_id): - """ - Returns the count of active enrollments in a course. - - 'course_id' is the course_id to return enrollments - """ - enrollment_number = CourseEnrollment.objects.filter(course_id=course_id, is_active=1).count() - - return enrollment_number - @classmethod def is_enrollment_closed(cls, user, course): """ @@ -853,17 +904,6 @@ class CourseEnrollment(models.Model): from courseware.access import has_access # pylint: disable=import-error return not has_access(user, 'enroll', course) - @classmethod - def is_course_full(cls, course): - """ - Returns a boolean value regarding whether a course has already reached it's max enrollment - capacity - """ - is_course_full = False - if course.max_student_enrollments_allowed is not None: - is_course_full = cls.num_enrolled_in(course.id) >= course.max_student_enrollments_allowed - return is_course_full - def update_enrollment(self, mode=None, is_active=None, skip_refund=False): """ Updates an enrollment for a user in a class. This includes options @@ -1015,7 +1055,7 @@ class CourseEnrollment(models.Model): ) raise EnrollmentClosedError - if CourseEnrollment.is_course_full(course): + if CourseEnrollment.objects.is_course_full(course): log.warning( u"User %s failed to enroll in full course %s", user.username, @@ -1187,30 +1227,6 @@ class CourseEnrollment(models.Model): def enrollments_for_user(cls, user): return CourseEnrollment.objects.filter(user=user, is_active=1) - @classmethod - def users_enrolled_in(cls, course_id): - """Return a queryset of User for every user enrolled in the course.""" - return User.objects.filter( - courseenrollment__course_id=course_id, - courseenrollment__is_active=True - ) - - @classmethod - def enrollment_counts(cls, course_id): - """ - Returns a dictionary that stores the total enrollment count for a course, as well as the - enrollment count for each individual mode. - """ - # Unfortunately, Django's "group by"-style queries look super-awkward - query = use_read_replica_if_available(cls.objects.filter(course_id=course_id, is_active=True).values('mode').order_by().annotate(Count('mode'))) - total = 0 - enroll_dict = defaultdict(int) - for item in query: - enroll_dict[item['mode']] = item['mode__count'] - total += item['mode__count'] - enroll_dict['total'] = total - return enroll_dict - def is_paid_course(self): """ Returns True, if course is paid diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index a9856a9886..33247d19f8 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -857,7 +857,7 @@ def course_about(request, course_id): # Used to provide context to message to student if enrollment not allowed can_enroll = has_access(request.user, 'enroll', course) invitation_only = course.invitation_only - is_course_full = CourseEnrollment.is_course_full(course) + is_course_full = CourseEnrollment.objects.is_course_full(course) # Register button should be disabled if one of the following is true: # - Student is already registered for course diff --git a/lms/djangoapps/instructor/enrollment_report.py b/lms/djangoapps/instructor/enrollment_report.py new file mode 100644 index 0000000000..a031313d46 --- /dev/null +++ b/lms/djangoapps/instructor/enrollment_report.py @@ -0,0 +1,98 @@ +""" +Defines abstract class for the Enrollment Reports. +""" + +from django.contrib.auth.models import User +from student.models import UserProfile +import collections +import json +import abc + + +class AbstractEnrollmentReportProvider(object): + """ + Abstract interface for Detailed Enrollment Report Provider + """ + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def get_enrollment_info(self, user, course_id): + """ + Returns the User Enrollment information. + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_user_profile(self, user_id): + """ + Returns the UserProfile information. + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_payment_info(self, user, course_id): + """ + Returns the User Payment information. + """ + raise NotImplementedError() + + +class BaseAbstractEnrollmentReportProvider(AbstractEnrollmentReportProvider): + """ + The base abstract class for all Enrollment Reports that can support multiple + backend such as MySQL/Django-ORM. + + # don't allow instantiation of this class, it must be subclassed + """ + def get_user_profile(self, user_id): + """ + Returns the UserProfile information. + """ + user_info = User.objects.select_related('profile').get(id=user_id) + # extended user profile fields are stored in the user_profile meta column + meta = {} + if user_info.profile.meta: + meta = json.loads(user_info.profile.meta) + + user_data = collections.OrderedDict() + user_data['User ID'] = user_info.id + user_data['Username'] = user_info.username + user_data['Full Name'] = user_info.profile.name + user_data['First Name'] = meta.get('first-name', '') + user_data['Last Name'] = meta.get('last-name', '') + user_data['Company Name'] = meta.get('company', '') + user_data['Title'] = meta.get('title', '') + user_data['Language'] = user_info.profile.language + user_data['Country'] = user_info.profile.country + user_data['Year of Birth'] = user_info.profile.year_of_birth + + user_data['Gender'] = None + gender = user_info.profile.gender + for _gender in UserProfile.GENDER_CHOICES: + if gender == _gender[0]: + user_data['Gender'] = _gender[1] + break + + user_data['Level of Education'] = None + level_of_education = user_info.profile.level_of_education + for _loe in UserProfile.LEVEL_OF_EDUCATION_CHOICES: + if level_of_education == _loe[0]: + user_data['Level of Education'] = _loe[1] + + user_data['Mailing Address'] = user_info.profile.mailing_address + user_data['Goals'] = user_info.profile.goals + user_data['City'] = user_info.profile.city + user_data['Country'] = user_info.profile.country + return user_data + + def get_enrollment_info(self, user, course_id): + """ + Returns the User Enrollment information. + """ + raise NotImplementedError() + + def get_payment_info(self, user, course_id): + """ + Returns the User Payment information. + """ + raise NotImplementedError() diff --git a/lms/djangoapps/instructor/management/commands/openended_stats.py b/lms/djangoapps/instructor/management/commands/openended_stats.py index 8f0b48a731..e772c0b969 100644 --- a/lms/djangoapps/instructor/management/commands/openended_stats.py +++ b/lms/djangoapps/instructor/management/commands/openended_stats.py @@ -55,7 +55,7 @@ class Command(BaseCommand): return try: - enrolled_students = CourseEnrollment.users_enrolled_in(course_id) + enrolled_students = CourseEnrollment.objects.users_enrolled_in(course_id) print "Total students enrolled in {0}: {1}".format(course_id, enrolled_students.count()) calculate_task_statistics(enrolled_students, course, usage_key, task_number) diff --git a/lms/djangoapps/instructor/paidcourse_enrollment_report.py b/lms/djangoapps/instructor/paidcourse_enrollment_report.py new file mode 100644 index 0000000000..cb9aab68e8 --- /dev/null +++ b/lms/djangoapps/instructor/paidcourse_enrollment_report.py @@ -0,0 +1,178 @@ +""" +Defines concrete class for cybersource Enrollment Report. + +""" +from courseware.access import has_access +import collections +from django.utils.translation import ugettext as _ +from courseware.courses import get_course_by_id +from instructor.enrollment_report import BaseAbstractEnrollmentReportProvider +from shoppingcart.models import RegistrationCodeRedemption, PaidCourseRegistration, CouponRedemption, OrderItem, \ + InvoiceTransaction +from student.models import CourseEnrollment + + +class PaidCourseEnrollmentReportProvider(BaseAbstractEnrollmentReportProvider): + """ + The concrete class for all CyberSource Enrollment Reports. + """ + + def get_enrollment_info(self, user, course_id): + """ + Returns the User Enrollment information. + """ + course = get_course_by_id(course_id, depth=0) + is_course_staff = has_access(user, 'staff', course) + + # check the user enrollment role + if user.is_staff: + enrollment_role = _('Edx Staff') + elif is_course_staff: + enrollment_role = _('Course Staff') + else: + enrollment_role = _('Student') + + course_enrollment = CourseEnrollment.get_enrollment(user=user, course_key=course_id) + + if is_course_staff: + enrollment_source = _('Staff') + else: + # get the registration_code_redemption object if exists + registration_code_redemption = RegistrationCodeRedemption.registration_code_used_for_enrollment( + course_enrollment) + # get the paid_course registration item if exists + paid_course_reg_item = PaidCourseRegistration.get_course_item_for_user_enrollment( + user=user, + course_id=course_id, + course_enrollment=course_enrollment + ) + + # from where the user get here + if registration_code_redemption is not None: + enrollment_source = _('Used Registration Code') + elif paid_course_reg_item is not None: + enrollment_source = _('Credit Card - Individual') + else: + enrollment_source = _('Manually Enrolled') + + enrollment_date = course_enrollment.created.strftime("%B %d, %Y") + currently_enrolled = course_enrollment.is_active + + course_enrollment_data = collections.OrderedDict() + course_enrollment_data['Enrollment Date'] = enrollment_date + course_enrollment_data['Currently Enrolled'] = currently_enrolled + course_enrollment_data['Enrollment Source'] = enrollment_source + course_enrollment_data['Enrollment Role'] = enrollment_role + return course_enrollment_data + + def get_payment_info(self, user, course_id): + """ + Returns the User Payment information. + """ + course_enrollment = CourseEnrollment.get_enrollment(user=user, course_key=course_id) + paid_course_reg_item = PaidCourseRegistration.get_course_item_for_user_enrollment( + user=user, + course_id=course_id, + course_enrollment=course_enrollment + ) + payment_data = collections.OrderedDict() + # check if the user made a single self purchase scenario + # for enrollment in the course. + if paid_course_reg_item is not None: + coupon_redemption = CouponRedemption.objects.select_related('coupon').filter( + order_id=paid_course_reg_item.order_id) + coupon_codes = [redemption.coupon.code for redemption in coupon_redemption] + coupon_codes = ", ".join(coupon_codes) + registration_code_used = 'N/A' + + if coupon_redemption.exists(): + list_price = paid_course_reg_item.list_price + else: + list_price = paid_course_reg_item.unit_cost + + payment_amount = paid_course_reg_item.unit_cost + coupon_codes_used = coupon_codes + payment_status = paid_course_reg_item.status + transaction_reference_number = paid_course_reg_item.order_id + + else: + # check if the user used a registration code for the enrollment. + registration_code_redemption = RegistrationCodeRedemption.registration_code_used_for_enrollment( + course_enrollment) + if registration_code_redemption is not None: + registration_code = registration_code_redemption.registration_code + registration_code_used = registration_code.code + if getattr(registration_code, 'invoice_item_id'): + list_price, payment_amount, payment_status, transaction_reference_number =\ + self._get_invoice_data(registration_code_redemption) + coupon_codes_used = 'N/A' + + elif getattr(registration_code_redemption.registration_code, 'order_id'): + list_price, payment_amount, coupon_codes_used, payment_status, transaction_reference_number = \ + self._get_order_data(registration_code_redemption, course_id) + + else: + # this happens when the registration code is not created via invoice or bulk purchase + # scenario. + list_price = 'N/A' + payment_amount = 'N/A' + coupon_codes_used = 'N/A' + registration_code_used = 'N/A' + payment_status = _('Data Integrity Error') + transaction_reference_number = 'N/A' + else: + list_price = 'N/A' + payment_amount = 'N/A' + coupon_codes_used = 'N/A' + registration_code_used = 'N/A' + payment_status = _('TBD') + transaction_reference_number = 'N/A' + + payment_data['List Price'] = list_price + payment_data['Payment Amount'] = payment_amount + payment_data['Coupon Codes Used'] = coupon_codes_used + payment_data['Registration Code Used'] = registration_code_used + payment_data['Payment Status'] = payment_status + payment_data['Transaction Reference Number'] = transaction_reference_number + return payment_data + + def _get_order_data(self, registration_code_redemption, course_id): + """ + Returns the order data + """ + order_item = OrderItem.objects.get(order=registration_code_redemption.registration_code.order, + courseregcodeitem__course_id=course_id) + coupon_redemption = CouponRedemption.objects.select_related('coupon').filter( + order_id=registration_code_redemption.registration_code.order) + coupon_codes = [redemption.coupon.code for redemption in coupon_redemption] + coupon_codes = ", ".join(coupon_codes) + + list_price = order_item.list_price + payment_amount = order_item.unit_cost + coupon_codes_used = coupon_codes + payment_status = order_item.status + transaction_reference_number = order_item.order_id + return list_price, payment_amount, coupon_codes_used, payment_status, transaction_reference_number + + def _get_invoice_data(self, registration_code_redemption): + """ + Returns the Invoice data + """ + registration_code = registration_code_redemption.registration_code + list_price = getattr(registration_code.invoice_item, 'unit_price') + total_amount = registration_code_redemption.registration_code.invoice.total_amount + qty = registration_code_redemption.registration_code.invoice_item.qty + payment_amount = total_amount / qty + invoice_transaction = InvoiceTransaction.get_invoice_transaction( + invoice_id=registration_code_redemption.registration_code.invoice.id) + if invoice_transaction is not None: + # amount greater than 0 is invoice has bee paid + if invoice_transaction.amount > 0: + payment_status = 'Invoice Paid' + else: + # amount less than 0 is invoice has been refunded + payment_status = 'Refunded' + else: + payment_status = 'Invoice Outstanding' + transaction_reference_number = registration_code_redemption.registration_code.invoice_id + return list_price, payment_amount, payment_status, transaction_reference_number diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index b82aa88b81..6fd54fa409 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -32,20 +32,20 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from course_modes.models import CourseMode from courseware.models import StudentModule -from courseware.tests.factories import StaffFactory, InstructorFactory, BetaTesterFactory +from courseware.tests.factories import StaffFactory, InstructorFactory, BetaTesterFactory, UserProfileFactory from courseware.tests.helpers import LoginEnrollmentTestCase from django_comment_common.models import FORUM_ROLE_COMMUNITY_TA from django_comment_common.utils import seed_permissions_roles from microsite_configuration import microsite from shoppingcart.models import ( RegistrationCodeRedemption, Order, CouponRedemption, - PaidCourseRegistration, Coupon, Invoice, CourseRegistrationCode, CourseRegistrationCodeInvoiceItem -) + PaidCourseRegistration, Coupon, Invoice, CourseRegistrationCode, CourseRegistrationCodeInvoiceItem, + InvoiceTransaction) from shoppingcart.pdf import PDFInvoice from student.models import ( CourseEnrollment, CourseEnrollmentAllowed, NonExistentCourseError ) -from student.tests.factories import UserFactory, CourseModeFactory +from student.tests.factories import UserFactory, CourseModeFactory, AdminFactory from student.roles import CourseBetaTesterRole, CourseSalesAdminRole, CourseFinanceAdminRole, CourseInstructorRole from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -57,6 +57,7 @@ from courseware.models import StudentFieldOverride import instructor_task.api import instructor.views.api +from instructor.views.api import require_finance_admin from instructor.tests.utils import FakeContentTask, FakeEmail, FakeEmailInfo from instructor.views.api import generate_unique_password from instructor.views.api import _split_input_list, common_exceptions_400 @@ -86,6 +87,12 @@ REPORTS_DATA = ( 'instructor_api_endpoint': 'get_students_features', 'task_api_endpoint': 'instructor_task.api.submit_calculate_students_features_csv', 'extra_instructor_api_kwargs': {'csv': '/csv'} + }, + { + 'report_type': 'detailed enrollment', + 'instructor_api_endpoint': 'get_enrollment_report', + 'task_api_endpoint': 'instructor_task.api.submit_detailed_enrollment_features_csv', + 'extra_instructor_api_kwargs': {} } ) @@ -193,8 +200,10 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): ('list_instructor_tasks', {}), ('list_background_email_tasks', {}), ('list_report_downloads', {}), + ('list_financial_report_downloads', {}), ('calculate_grades_csv', {}), ('get_students_features', {}), + ('get_enrollment_report', {}), ] # Endpoints that only Instructors can access self.instructor_level_endpoints = [ @@ -253,6 +262,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): """ staff_member = StaffFactory(course_key=self.course.id) CourseEnrollment.enroll(staff_member, self.course.id) + CourseFinanceAdminRole(self.course.id).add_users(staff_member) self.client.login(username=staff_member.username, password='test') # Try to promote to forums admin - not working # update_forum_role(self.course.id, staff_member, FORUM_ROLE_ADMINISTRATOR, 'allow') @@ -282,6 +292,8 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): """ inst = InstructorFactory(course_key=self.course.id) CourseEnrollment.enroll(inst, self.course.id) + + CourseFinanceAdminRole(self.course.id).add_users(inst) self.client.login(username=inst.username, password='test') for endpoint, args in self.staff_level_endpoints: @@ -1742,6 +1754,20 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa for student in self.students: CourseEnrollment.enroll(student, self.course.id) + def register_with_redemption_code(self, user, code): + """ + enroll user using a registration code + """ + redeem_url = reverse('register_code_redemption', args=[code]) + self.client.login(username=user.username, password='test') + response = self.client.get(redeem_url) + self.assertEquals(response.status_code, 200) + # check button text + self.assertTrue('Activate Course Enrollment' in response.content) + + response = self.client.post(redeem_url) + self.assertEquals(response.status_code, 200) + def test_invalidate_sale_record(self): """ Testing the sale invalidating scenario. @@ -2022,6 +2048,187 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa self.assertEqual('cohort' in res_json['feature_names'], is_cohorted) + def test_access_course_finance_admin_with_invalid_course_key(self): + """ + Test assert require_course fiance_admin before generating + a detailed enrollment report + """ + func = Mock() + decorated_func = require_finance_admin(func) + request = self.mock_request() + response = decorated_func(request, 'invalid_course_key') + self.assertEqual(response.status_code, 404) + self.assertFalse(func.called) + + def mock_request(self): + """ + mock request + """ + request = Mock() + request.user = self.instructor + return request + + def test_access_course_finance_admin_with_valid_course_key(self): + """ + Test to check the course_finance_admin role with valid key + but doesn't have access to the function + """ + func = Mock() + decorated_func = require_finance_admin(func) + request = self.mock_request() + response = decorated_func(request, 'valid/course/key') + self.assertEqual(response.status_code, 403) + self.assertFalse(func.called) + + def test_add_user_to_fiance_admin_role_with_valid_course(self): + """ + test to check that a function is called using a fiance_admin + rights. + """ + func = Mock() + decorated_func = require_finance_admin(func) + request = self.mock_request() + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + decorated_func(request, self.course.id.to_deprecated_string()) + self.assertTrue(func.called) + + def test_enrollment_report_features_csv(self): + """ + test to generate enrollment report. + enroll users, admin staff using registration codes. + """ + InvoiceTransaction.objects.create( + invoice=self.sale_invoice_1, + amount=self.sale_invoice_1.total_amount, + status='completed', + created_by=self.instructor, + last_modified_by=self.instructor + ) + course_registration_code = CourseRegistrationCode.objects.create( + code='abcde', + course_id=self.course.id.to_deprecated_string(), + created_by=self.instructor, + invoice=self.sale_invoice_1, + invoice_item=self.invoice_item, + mode_slug='honor' + ) + + admin_user = AdminFactory() + admin_cart = Order.get_cart_for_user(admin_user) + PaidCourseRegistration.add_to_order(admin_cart, self.course.id) + admin_cart.purchase() + + # create a new user/student and enroll + # in the course using a registration code + # and then validates the generated detailed enrollment report + test_user = UserFactory() + self.register_with_redemption_code(test_user, course_registration_code.code) + + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + UserProfileFactory.create(user=self.students[0], meta='{"company": "asdasda"}') + + self.client.login(username=self.instructor.username, password='test') + url = reverse('get_enrollment_report', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url, {}) + self.assertIn('Your detailed enrollment report is being generated!', response.content) + + def test_bulk_purchase_detailed_report(self): + """ + test to generate detailed enrollment report. + 1 Purchase registration codes. + 2 Enroll users via registration code. + 3 Validate generated enrollment report. + """ + paid_course_reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course.id) + # update the quantity of the cart item paid_course_reg_item + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), + {'ItemId': paid_course_reg_item.id, 'qty': '4'}) + self.assertEqual(resp.status_code, 200) + # apply the coupon code to the item in the cart + resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + self.cart.purchase() + + course_reg_codes = CourseRegistrationCode.objects.filter(order=self.cart) + self.register_with_redemption_code(self.instructor, course_reg_codes[0].code) + + test_user = UserFactory() + test_user_cart = Order.get_cart_for_user(test_user) + PaidCourseRegistration.add_to_order(test_user_cart, self.course.id) + test_user_cart.purchase() + InvoiceTransaction.objects.create( + invoice=self.sale_invoice_1, + amount=-self.sale_invoice_1.total_amount, + status='refunded', + created_by=self.instructor, + last_modified_by=self.instructor + ) + course_registration_code = CourseRegistrationCode.objects.create( + code='abcde', + course_id=self.course.id.to_deprecated_string(), + created_by=self.instructor, + invoice=self.sale_invoice_1, + invoice_item=self.invoice_item, + mode_slug='honor' + ) + + test_user1 = UserFactory() + self.register_with_redemption_code(test_user1, course_registration_code.code) + + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + self.client.login(username=self.instructor.username, password='test') + + url = reverse('get_enrollment_report', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url, {}) + self.assertIn('Your detailed enrollment report is being generated!', response.content) + + def test_create_registration_code_without_invoice_and_order(self): + """ + test generate detailed enrollment report, + used a registration codes which has been created via invoice or bulk + purchase scenario. + """ + course_registration_code = CourseRegistrationCode.objects.create( + code='abcde', + course_id=self.course.id.to_deprecated_string(), + created_by=self.instructor, + mode_slug='honor' + ) + test_user1 = UserFactory() + self.register_with_redemption_code(test_user1, course_registration_code.code) + + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + self.client.login(username=self.instructor.username, password='test') + + url = reverse('get_enrollment_report', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url, {}) + self.assertIn('Your detailed enrollment report is being generated!', response.content) + + def test_invoice_payment_is_still_pending_for_registration_codes(self): + """ + test generate enrollment report + enroll a user in a course using registration code + whose invoice has not been paid yet + """ + course_registration_code = CourseRegistrationCode.objects.create( + code='abcde', + course_id=self.course.id.to_deprecated_string(), + created_by=self.instructor, + invoice=self.sale_invoice_1, + invoice_item=self.invoice_item, + mode_slug='honor' + ) + + test_user1 = UserFactory() + self.register_with_redemption_code(test_user1, course_registration_code.code) + + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + self.client.login(username=self.instructor.username, password='test') + + url = reverse('get_enrollment_report', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url, {}) + self.assertIn('Your detailed enrollment report is being generated!', response.content) + @patch.object(instructor.views.api, 'anonymous_id_for_user', Mock(return_value='42')) @patch.object(instructor.views.api, 'unique_id_for_user', Mock(return_value='41')) def test_get_anon_ids(self): @@ -2071,6 +2278,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa kwargs.update(extra_instructor_api_kwargs) url = reverse(instructor_api_endpoint, kwargs=kwargs) + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) with patch(task_api_endpoint): response = self.client.get(url, {}) success_status = "Your {report_type} report is being generated! You can view the status of the generation task in the 'Pending Instructor Tasks' section.".format(report_type=report_type) @@ -2083,6 +2291,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa kwargs.update(extra_instructor_api_kwargs) url = reverse(instructor_api_endpoint, kwargs=kwargs) + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) with patch(task_api_endpoint) as mock: mock.side_effect = AlreadyRunningError() response = self.client.get(url, {}) diff --git a/lms/djangoapps/instructor/tests/test_enrollment_store_provider.py b/lms/djangoapps/instructor/tests/test_enrollment_store_provider.py new file mode 100644 index 0000000000..5909f28fa5 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_enrollment_store_provider.py @@ -0,0 +1,75 @@ +""" +Exercises tests on the base_store_provider file +""" + +from django.test import TestCase +from instructor.enrollment_report import AbstractEnrollmentReportProvider +from instructor.paidcourse_enrollment_report import PaidCourseEnrollmentReportProvider + + +class BadImplementationAbstractEnrollmentReportProvider(AbstractEnrollmentReportProvider): + """ + Test implementation of EnrollmentProvider to assert that non-implementations of methods + raises the correct methods + """ + + def get_user_profile(self, user_id): + """ + Fake implementation of method which calls base class, which should throw NotImplementedError + """ + super(BadImplementationAbstractEnrollmentReportProvider, self).get_user_profile(user_id) + + def get_enrollment_info(self, user, course_id): + """ + Fake implementation of method which calls base class, which should throw NotImplementedError + """ + super(BadImplementationAbstractEnrollmentReportProvider, self).get_enrollment_info(user, course_id) + + def get_payment_info(self, user, course_id): + """ + Fake implementation of method which calls base class, which should throw NotImplementedError + """ + super(BadImplementationAbstractEnrollmentReportProvider, self).get_payment_info(user, course_id) + + +class TestBaseNotificationDataProvider(TestCase): + """ + Cover the EnrollmentReportProvider class + """ + + def test_cannot_create_instance(self): + """ + EnrollmentReportProvider is an abstract class and we should not be able + to create an instance of it + """ + + with self.assertRaises(TypeError): + # parent of the BaseEnrollmentReportProvider is EnrollmentReportProvider + super(BadImplementationAbstractEnrollmentReportProvider, self) + + def test_get_provider(self): + """ + Makes sure we get an instance of the registered enrollment provider + """ + + provider = PaidCourseEnrollmentReportProvider() + + self.assertIsNotNone(provider) + self.assertTrue(isinstance(provider, PaidCourseEnrollmentReportProvider)) + + def test_base_methods_exceptions(self): + """ + Asserts that all base-methods on the EnrollmentProvider interface will throw + an NotImplementedError + """ + + bad_provider = BadImplementationAbstractEnrollmentReportProvider() + + with self.assertRaises(NotImplementedError): + bad_provider.get_enrollment_info(None, None) + + with self.assertRaises(NotImplementedError): + bad_provider.get_payment_info(None, None) + + with self.assertRaises(NotImplementedError): + bad_provider.get_user_profile(None) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 407d8bfcf7..27d8fc3b0c 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -30,7 +30,7 @@ import unicodecsv import urllib import decimal from student import auth -from student.roles import GlobalStaff, CourseSalesAdminRole +from student.roles import GlobalStaff, CourseSalesAdminRole, CourseFinanceAdminRole from util.file import store_uploaded_file, course_and_time_based_filename_generator, FileValidationException, UniversalNewlineIterator from util.json_request import JsonResponse from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features @@ -277,6 +277,31 @@ def require_sales_admin(func): return wrapped +def require_finance_admin(func): + """ + Decorator for checking finance administrator access before executing an HTTP endpoint. This decorator + is designed to be used for a request based action on a course. It assumes that there will be a + request object as well as a course_id attribute to leverage to check course level privileges. + + If the user does not have privileges for this operation, this will return HttpResponseForbidden (403). + """ + def wrapped(request, course_id): # pylint: disable=missing-docstring + + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + log.error(u"Unable to find course with course key %s", course_id) + return HttpResponseNotFound() + + access = auth.has_access(request.user, CourseFinanceAdminRole(course_key)) + + if access: + return func(request, course_id) + else: + return HttpResponseForbidden() + return wrapped + + EMAIL_INDEX = 0 USERNAME_INDEX = 1 NAME_INDEX = 2 @@ -1092,6 +1117,29 @@ def get_coupon_codes(request, course_id): # pylint: disable=unused-argument return instructor_analytics.csvs.create_csv_response('Coupons.csv', header, data_rows) +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +@require_finance_admin +def get_enrollment_report(request, course_id): + """ + get the enrollment report for the particular course. + """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + try: + instructor_task.api.submit_detailed_enrollment_features_csv(request, course_key) + success_status = _("Your detailed enrollment report is being generated! " + "You can view the status of the generation task in the 'Pending Instructor Tasks' section.") + return JsonResponse({"status": success_status}) + except AlreadyRunningError: + already_running_status = _("A detailed enrollment report generation task is already in progress. " + "Check the 'Pending Instructor Tasks' table for the status of the task. " + "When completed, the report will be available for download in the table below.") + return JsonResponse({ + "status": already_running_status + }) + + def save_registration_code(user, course_id, mode_slug, invoice=None, order=None, invoice_item=None): """ recursive function that generate a new code every time and saves in the Course Registration Table @@ -1918,7 +1966,27 @@ def list_report_downloads(_request, course_id): List grade CSV files that are available for download for this course. """ course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) - report_store = ReportStore.from_config() + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') + + response_payload = { + 'downloads': [ + dict(name=name, url=url, link='{}'.format(url, name)) + for name, url in report_store.links_for(course_id) + ] + } + return JsonResponse(response_payload) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +@require_finance_admin +def list_financial_report_downloads(_request, course_id): + """ + List grade CSV files that are available for download for this course. + """ + course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + report_store = ReportStore.from_config(config_name='FINANCIAL_REPORTS') response_payload = { 'downloads': [ diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 0991108ba2..a12a38bcc9 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -90,6 +90,10 @@ urlpatterns = patterns( url(r'problem_grade_report$', 'instructor.views.api.problem_grade_report', name="problem_grade_report"), + # Financial Report downloads.. + url(r'^list_financial_report_downloads$', + 'instructor.views.api.list_financial_report_downloads', name="list_financial_report_downloads"), + # Registration Codes.. url(r'get_registration_codes$', 'instructor.views.api.get_registration_codes', name="get_registration_codes"), @@ -100,6 +104,11 @@ urlpatterns = patterns( url(r'spent_registration_codes$', 'instructor.views.api.spent_registration_codes', name="spent_registration_codes"), + # Reports.. + url(r'get_enrollment_report$', + 'instructor.views.api.get_enrollment_report', name="get_enrollment_report"), + + # Coupon Codes.. url(r'get_coupon_codes', 'instructor.views.api.get_coupon_codes', name="get_coupon_codes"), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index d48d847aa5..103b137652 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -107,7 +107,7 @@ def instructor_dashboard_2(request, course_id): # Gate access to Ecommerce tab if course_mode_has_price and (access['finance_admin'] or access['sales_admin']): - sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label)) + sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label, is_white_label)) # Certificates panel # This is used to generate example certificates @@ -150,7 +150,7 @@ def instructor_dashboard_2(request, course_id): ## section_display_name will be used to generate link titles in the nav bar. -def _section_e_commerce(course, access, paid_mode, coupons_enabled): +def _section_e_commerce(course, access, paid_mode, coupons_enabled, reports_enabled): """ Provide data for the corresponding dashboard section """ course_key = course.id coupons = Coupon.objects.filter(course_id=course_key).order_by('-is_active') @@ -183,9 +183,14 @@ def _section_e_commerce(course, access, paid_mode, coupons_enabled): 'spent_registration_code_csv_url': reverse('spent_registration_codes', kwargs={'course_id': unicode(course_key)}), 'set_course_mode_url': reverse('set_course_mode_price', kwargs={'course_id': unicode(course_key)}), 'download_coupon_codes_url': reverse('get_coupon_codes', kwargs={'course_id': unicode(course_key)}), + 'enrollment_report_url': reverse('get_enrollment_report', kwargs={'course_id': unicode(course_key)}), + 'list_financial_report_downloads_url': reverse('list_financial_report_downloads', + kwargs={'course_id': unicode(course_key)}), + 'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': unicode(course_key)}), 'coupons': coupons, 'sales_admin': access['sales_admin'], 'coupons_enabled': coupons_enabled, + 'reports_enabled': reports_enabled, 'course_price': course_price, 'total_amount': total_amount } @@ -291,7 +296,7 @@ def _section_course_info(course, access): } if settings.FEATURES.get('DISPLAY_ANALYTICS_ENROLLMENTS'): - section_data['enrollment_count'] = CourseEnrollment.enrollment_counts(course_key) + section_data['enrollment_count'] = CourseEnrollment.objects.enrollment_counts(course_key) if settings.ANALYTICS_DASHBOARD_URL: dashboard_link = _get_dashboard_link(course_key) @@ -356,7 +361,7 @@ def _section_cohort_management(course, access): def _is_small_course(course_key): """ Compares against MAX_ENROLLMENT_INSTR_BUTTONS to determine if course enrollment is considered small. """ is_small_course = False - enrollment_count = CourseEnrollment.num_enrolled_in(course_key) + enrollment_count = CourseEnrollment.objects.num_enrolled_in(course_key) max_enrollment_for_buttons = settings.FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS") if max_enrollment_for_buttons is not None: is_small_course = enrollment_count <= max_enrollment_for_buttons diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 4f208cb343..037fe0e07c 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -102,7 +102,7 @@ def instructor_dashboard(request, course_id): else: idash_mode = request.session.get(idash_mode_key, 'Grades') - enrollment_number = CourseEnrollment.num_enrolled_in(course_key) + enrollment_number = CourseEnrollment.objects.num_enrolled_in(course_key) # assemble some course statistics for output to instructor def get_course_stats_table(): diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index b02fc1470b..35c81bee69 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -22,7 +22,7 @@ from instructor_task.tasks import ( calculate_problem_grade_report, calculate_students_features_csv, cohort_students, -) + enrollment_report_features_csv) from instructor_task.api_helper import ( check_arguments_for_rescoring, @@ -361,6 +361,20 @@ def submit_calculate_students_features_csv(request, course_key, features): return submit_task(request, task_type, task_class, course_key, task_input, task_key) +def submit_detailed_enrollment_features_csv(request, course_key): # pylint: disable=invalid-name + """ + Submits a task to generate a CSV containing detailed enrollment info. + + Raises AlreadyRunningError if said CSV is already being updated. + """ + task_type = 'detailed_enrollment_report' + task_class = enrollment_report_features_csv + task_input = {} + task_key = "" + + return submit_task(request, task_type, task_class, course_key, task_input, task_key) + + def submit_cohort_students(request, course_key, file_name): """ Request to have students cohorted in bulk. diff --git a/lms/djangoapps/instructor_task/models.py b/lms/djangoapps/instructor_task/models.py index 32415185fb..3368350233 100644 --- a/lms/djangoapps/instructor_task/models.py +++ b/lms/djangoapps/instructor_task/models.py @@ -198,16 +198,16 @@ class ReportStore(object): passing in the whole dataset. Doing that for now just because it's simpler. """ @classmethod - def from_config(cls): + def from_config(cls, config_name): """ Return one of the ReportStore subclasses depending on django configuration. Look at subclasses for expected configuration. """ - storage_type = settings.GRADES_DOWNLOAD.get("STORAGE_TYPE") + storage_type = getattr(settings, config_name).get("STORAGE_TYPE") if storage_type.lower() == "s3": - return S3ReportStore.from_config() + return S3ReportStore.from_config(config_name) elif storage_type.lower() == "localfs": - return LocalFSReportStore.from_config() + return LocalFSReportStore.from_config(config_name) def _get_utf8_encoded_rows(self, rows): """ @@ -242,7 +242,7 @@ class S3ReportStore(ReportStore): self.bucket = conn.get_bucket(bucket_name) @classmethod - def from_config(cls): + def from_config(cls, config_name): """ The expected configuration for an `S3ReportStore` is to have a `GRADES_DOWNLOAD` dict in settings with the following fields:: @@ -257,8 +257,8 @@ class S3ReportStore(ReportStore): and `AWS_SECRET_ACCESS_KEY` in settings. """ return cls( - settings.GRADES_DOWNLOAD['BUCKET'], - settings.GRADES_DOWNLOAD['ROOT_PATH'] + getattr(settings, config_name).get("BUCKET"), + getattr(settings, config_name).get("ROOT_PATH") ) def key_for(self, course_id, filename): @@ -354,7 +354,7 @@ class LocalFSReportStore(ReportStore): os.makedirs(root_path) @classmethod - def from_config(cls): + def from_config(cls, config_name): """ Generate an instance of this object from Django settings. It assumes that there is a dict in settings named GRADES_DOWNLOAD and that it has @@ -365,7 +365,7 @@ class LocalFSReportStore(ReportStore): STORAGE_TYPE : "localfs" ROOT_PATH : /tmp/edx/report-downloads/ """ - return cls(settings.GRADES_DOWNLOAD['ROOT_PATH']) + return cls(getattr(settings, config_name).get("ROOT_PATH")) def path_to(self, course_id, filename): """Return the full path to a given file for a given course.""" diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py index fa84cf5ca7..2b81c9463d 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -37,8 +37,8 @@ from instructor_task.tasks_helper import ( upload_grades_csv, upload_problem_grade_report, upload_students_csv, - cohort_students_and_upload -) + cohort_students_and_upload, + upload_enrollment_report) TASK_LOG = logging.getLogger('edx.celery.task') @@ -185,6 +185,18 @@ def calculate_students_features_csv(entry_id, xmodule_instance_args): return run_main_task(entry_id, task_fn, action_name) +@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable +def enrollment_report_features_csv(entry_id, xmodule_instance_args): + """ + Compute student profile information for a course and upload the + CSV to an S3 bucket for download. + """ + # Translators: This is a past-tense verb that is inserted into task progress messages as {action}. + action_name = ugettext_noop('generating_enrollment_report') + task_fn = partial(upload_enrollment_report, xmodule_instance_args) + return run_main_task(entry_id, task_fn, action_name) + + @task(base=BaseInstructorTask) # pylint: disable=E1102 def cohort_students(entry_id, xmodule_instance_args): """ diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index d534277d11..59ba90a914 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -19,12 +19,13 @@ from django.core.files.storage import DefaultStorage from django.db import transaction, reset_queries import dogstats_wrapper as dog_stats_api from pytz import UTC +from instructor.paidcourse_enrollment_report import PaidCourseEnrollmentReportProvider from track.views import task_track from util.file import course_filename_prefix_generator, UniversalNewlineIterator from xmodule.modulestore.django import modulestore from xmodule.split_test_module import get_split_user_partitions - +from django.utils.translation import ugettext as _ from certificates.models import CertificateWhitelist, certificate_info_for_user from courseware.courses import get_course_by_id, get_problems_in_section from courseware.grades import iterate_grades_for @@ -532,7 +533,7 @@ def delete_problem_module_state(xmodule_instance_args, _module_descriptor, stude return UPDATE_STATUS_SUCCEEDED -def upload_csv_to_report_store(rows, csv_name, course_id, timestamp): +def upload_csv_to_report_store(rows, csv_name, course_id, timestamp, config_name='GRADES_DOWNLOAD'): """ Upload data as a CSV using ReportStore. @@ -546,7 +547,7 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp): csv_name: Name of the resulting CSV course_id: ID of the course """ - report_store = ReportStore.from_config() + report_store = ReportStore.from_config(config_name) report_store.store_rows( course_id, u"{course_prefix}_{csv_name}_{timestamp_str}.csv".format( @@ -575,7 +576,7 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, start_time = time() start_date = datetime.now(UTC) status_interval = 100 - enrolled_students = CourseEnrollment.users_enrolled_in(course_id) + enrolled_students = CourseEnrollment.objects.users_enrolled_in(course_id) task_progress = TaskProgress(action_name, enrolled_students.count(), start_time) fmt = u'Task: {task_id}, InstructorTask ID: {entry_id}, Course: {course_id}, Input: {task_input}' @@ -771,7 +772,7 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t start_time = time() start_date = datetime.now(UTC) status_interval = 100 - enrolled_students = CourseEnrollment.users_enrolled_in(course_id) + enrolled_students = CourseEnrollment.objects.users_enrolled_in(course_id) task_progress = TaskProgress(action_name, enrolled_students.count(), start_time) # This struct encapsulates both the display names of each static item in the @@ -842,7 +843,9 @@ def upload_students_csv(_xmodule_instance_args, _entry_id, course_id, task_input """ start_time = time() start_date = datetime.now(UTC) - task_progress = TaskProgress(action_name, CourseEnrollment.num_enrolled_in(course_id), start_time) + enrolled_students = CourseEnrollment.objects.users_enrolled_in(course_id) + task_progress = TaskProgress(action_name, enrolled_students.count(), start_time) + current_step = {'step': 'Calculating Profile Info'} task_progress.update_task_state(extra_meta=current_step) @@ -865,6 +868,126 @@ def upload_students_csv(_xmodule_instance_args, _entry_id, course_id, task_input return task_progress.update_task_state(extra_meta=current_step) +def upload_enrollment_report(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): + """ + For a given `course_id`, generate a CSV file containing profile + information for all students that are enrolled, and store using a + `ReportStore`. + """ + start_time = time() + start_date = datetime.now(UTC) + status_interval = 100 + students_in_course = CourseEnrollment.objects.enrolled_and_dropped_out_users(course_id) + task_progress = TaskProgress(action_name, students_in_course.count(), start_time) + + fmt = u'Task: {task_id}, InstructorTask ID: {entry_id}, Course: {course_id}, Input: {task_input}' + task_info_string = fmt.format( + task_id=_xmodule_instance_args.get('task_id') if _xmodule_instance_args is not None else None, + entry_id=_entry_id, + course_id=course_id, + task_input=_task_input + ) + TASK_LOG.info(u'%s, Task type: %s, Starting task execution', task_info_string, action_name) + + # Loop over all our students and build our CSV lists in memory + rows = [] + header = None + current_step = {'step': 'Gathering Profile Information'} + enrollment_report_provider = PaidCourseEnrollmentReportProvider() + total_students = students_in_course.count() + student_counter = 0 + TASK_LOG.info( + u'%s, Task type: %s, Current step: %s, generating detailed enrollment report for total students: %s', + task_info_string, + action_name, + current_step, + total_students + ) + + for student in students_in_course: + # Periodically update task status (this is a cache write) + if task_progress.attempted % status_interval == 0: + task_progress.update_task_state(extra_meta=current_step) + task_progress.attempted += 1 + + # Now add a log entry after certain intervals to get a hint that task is in progress + student_counter += 1 + if student_counter % 100 == 0: + TASK_LOG.info( + u'%s, Task type: %s, Current step: %s, gathering enrollment profile for students in progress: %s/%s', + task_info_string, + action_name, + current_step, + student_counter, + total_students + ) + + user_data = enrollment_report_provider.get_user_profile(student.id) + course_enrollment_data = enrollment_report_provider.get_enrollment_info(student, course_id) + payment_data = enrollment_report_provider.get_payment_info(student, course_id) + + # display name map for the column headers + enrollment_report_headers = { + 'User ID': _('User ID'), + 'Username': _('Username'), + 'Full Name': _('Full Name'), + 'First Name': _('First Name'), + 'Last Name': _('Last Name'), + 'Company Name': _('Company Name'), + 'Title': _('Title'), + 'Language': _('Language'), + 'Year of Birth': _('Year of Birth'), + 'Gender': _('Gender'), + 'Level of Education': _('Level of Education'), + 'Mailing Address': _('Mailing Address'), + 'Goals': _('Goals'), + 'City': _('City'), + 'Country': _('Country'), + 'Enrollment Date': _('Enrollment Date'), + 'Currently Enrolled': _('Currently Enrolled'), + 'Enrollment Source': _('Enrollment Source'), + 'Enrollment Role': _('Enrollment Role'), + 'List Price': _('List Price'), + 'Payment Amount': _('Payment Amount'), + 'Coupon Codes Used': _('Coupon Codes Used'), + 'Registration Code Used': _('Registration Code Used'), + 'Payment Status': _('Payment Status'), + 'Transaction Reference Number': _('Transaction Reference Number') + } + + if not header: + header = user_data.keys() + course_enrollment_data.keys() + payment_data.keys() + display_headers = [] + for header_element in header: + # translate header into a localizable display string + display_headers.append(enrollment_report_headers.get(header_element, header_element)) + rows.append(display_headers) + + rows.append(user_data.values() + course_enrollment_data.values() + payment_data.values()) + task_progress.succeeded += 1 + + TASK_LOG.info( + u'%s, Task type: %s, Current step: %s, Detailed enrollment report generated for students: %s/%s', + task_info_string, + action_name, + current_step, + student_counter, + total_students + ) + + # By this point, we've got the rows we're going to stuff into our CSV files. + current_step = {'step': 'Uploading CSVs'} + task_progress.update_task_state(extra_meta=current_step) + TASK_LOG.info(u'%s, Task type: %s, Current step: %s', task_info_string, action_name, current_step) + + # Perform the actual upload + upload_csv_to_report_store(rows, 'enrollment_report', course_id, start_date, config_name='FINANCIAL_REPORTS') + + # One last update before we close out... + TASK_LOG.info(u'%s, Task type: %s, Finalizing detailed enrollment task', task_info_string, action_name) + return task_progress.update_task_state(extra_meta=current_step) + + def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, task_input, action_name): """ Within a given course, cohort students in bulk, then upload the results diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index c55e9ed706..5e9a0b757a 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -16,7 +16,7 @@ from instructor_task.api import ( submit_bulk_course_email, submit_calculate_students_features_csv, submit_cohort_students, -) + submit_detailed_enrollment_features_csv) from instructor_task.api_helper import AlreadyRunningError from instructor_task.models import InstructorTask, PROGRESS @@ -207,6 +207,11 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa ) self._test_resubmission(api_call) + def test_submit_enrollment_report_features_csv(self): + api_call = lambda: submit_detailed_enrollment_features_csv(self.create_task_request(self.instructor), + self.course.id) + self._test_resubmission(api_call) + def test_submit_cohort_students(self): api_call = lambda: submit_cohort_students( self.create_task_request(self.instructor), diff --git a/lms/djangoapps/instructor_task/tests/test_base.py b/lms/djangoapps/instructor_task/tests/test_base.py index e7f18ce01e..40fd3bea93 100644 --- a/lms/djangoapps/instructor_task/tests/test_base.py +++ b/lms/djangoapps/instructor_task/tests/test_base.py @@ -314,7 +314,7 @@ class TestReportMixin(object): ignore_other_columns (boolean): When True, we verify that `expected_rows` contain data which is the subset of actual csv rows. """ - report_store = ReportStore.from_config() + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') report_csv_filename = report_store.links_for(self.course.id)[file_index][0] with open(report_store.path_to(self.course.id, report_csv_filename)) as csv_file: # Expand the dict reader generator so we don't lose it's content diff --git a/lms/djangoapps/instructor_task/tests/test_models.py b/lms/djangoapps/instructor_task/tests/test_models.py index f681b71f85..581138117d 100644 --- a/lms/djangoapps/instructor_task/tests/test_models.py +++ b/lms/djangoapps/instructor_task/tests/test_models.py @@ -91,7 +91,7 @@ class LocalFSReportStoreTestCase(ReportStoreTestMixin, TestReportMixin, TestCase """ def create_report_store(self): """ Create and return a LocalFSReportStore. """ - return LocalFSReportStore.from_config() + return LocalFSReportStore.from_config(config_name='GRADES_DOWNLOAD') @mock.patch('instructor_task.models.S3Connection', new=MockS3Connection) @@ -104,4 +104,4 @@ class S3ReportStoreTestCase(ReportStoreTestMixin, TestReportMixin, TestCase): """ def create_report_store(self): """ Create and return a S3ReportStore. """ - return S3ReportStore.from_config() + return S3ReportStore.from_config(config_name='GRADES_DOWNLOAD') diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 72b66354c7..f01cefc9ef 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -10,15 +10,22 @@ import ddt from mock import Mock, patch import tempfile import unicodecsv +from django.core.urlresolvers import reverse from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory from course_modes.models import CourseMode +from courseware.tests.factories import InstructorFactory +from instructor_task.models import ReportStore +from instructor_task.tasks_helper import cohort_students_and_upload, upload_grades_csv, upload_students_csv, \ + upload_enrollment_report from instructor_task.tests.test_base import InstructorTaskCourseTestCase, TestReportMixin, InstructorTaskModuleTestCase from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory import openedx.core.djangoapps.user_api.course_tag.api as course_tag_api from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme +from shoppingcart.models import Order, PaidCourseRegistration, CourseRegistrationCode, Invoice, \ + CourseRegistrationCodeInvoiceItem, InvoiceTransaction from student.tests.factories import UserFactory from student.models import CourseEnrollment from verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory @@ -70,7 +77,7 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase): result = upload_grades_csv(None, None, self.course.id, None, 'graded') self.assertDictContainsSubset({'attempted': 1, 'succeeded': 0, 'failed': 1}, result) - report_store = ReportStore.from_config() + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') self.assertTrue(any('grade_report_err' in item[0] for item in report_store.links_for(self.course.id))) def _verify_cell_data_for_user(self, username, course_id, column_header, expected_cell_content): @@ -80,7 +87,7 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase): with patch('instructor_task.tasks_helper._get_current_task'): result = upload_grades_csv(None, None, course_id, None, 'graded') self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, result) - report_store = ReportStore.from_config() + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') report_csv_filename = report_store.links_for(course_id)[0][0] with open(report_store.path_to(course_id, report_csv_filename)) as csv_file: for row in unicodecsv.DictReader(csv_file): @@ -264,6 +271,176 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase): self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) +@ddt.ddt +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) +class TestInstructorDetailedEnrollmentReport(TestReportMixin, InstructorTaskCourseTestCase): + """ + Tests that CSV detailed enrollment generation works. + """ + def setUp(self): + super(TestInstructorDetailedEnrollmentReport, self).setUp() + self.course = CourseFactory.create() + + # create testing invoice 1 + self.instructor = InstructorFactory(course_key=self.course.id) + self.sale_invoice_1 = Invoice.objects.create( + total_amount=1234.32, company_name='Test1', company_contact_name='TestName', + company_contact_email='Test@company.com', + recipient_name='Testw', recipient_email='test1@test.com', customer_reference_number='2Fwe23S', + internal_reference="A", course_id=self.course.id, is_valid=True + ) + self.invoice_item = CourseRegistrationCodeInvoiceItem.objects.create( + invoice=self.sale_invoice_1, + qty=1, + unit_price=1234.32, + course_id=self.course.id + ) + + def test_success(self): + self.create_student('student', 'student@example.com') + task_input = {'features': []} + with patch('instructor_task.tasks_helper._get_current_task'): + result = upload_enrollment_report(None, None, self.course.id, task_input, 'generating_enrollment_report') + + self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) + + def test_student_paid_course_enrollment_report(self): + """ + test to check the paid user enrollment csv report status + and enrollment source. + """ + student = UserFactory() + student_cart = Order.get_cart_for_user(student) + PaidCourseRegistration.add_to_order(student_cart, self.course.id) + student_cart.purchase() + + task_input = {'features': []} + with patch('instructor_task.tasks_helper._get_current_task'): + result = upload_enrollment_report(None, None, self.course.id, task_input, 'generating_enrollment_report') + self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) + self._verify_cell_data_in_csv(student.username, 'Enrollment Source', 'Credit Card - Individual') + self._verify_cell_data_in_csv(student.username, 'Payment Status', 'purchased') + + def test_student_used_enrollment_code_for_course_enrollment(self): + """ + test to check the user enrollment source and payment status in the + enrollment detailed report + """ + student = UserFactory() + self.client.login(username=student.username, password='test') + student_cart = Order.get_cart_for_user(student) + paid_course_reg_item = PaidCourseRegistration.add_to_order(student_cart, self.course.id) + # update the quantity of the cart item paid_course_reg_item + resp = self.client.post(reverse('shoppingcart.views.update_user_cart'), + {'ItemId': paid_course_reg_item.id, 'qty': '4'}) + self.assertEqual(resp.status_code, 200) + student_cart.purchase() + + course_reg_codes = CourseRegistrationCode.objects.filter(order=student_cart) + redeem_url = reverse('register_code_redemption', args=[course_reg_codes[0].code]) + response = self.client.get(redeem_url) + self.assertEquals(response.status_code, 200) + # check button text + self.assertTrue('Activate Course Enrollment' in response.content) + + response = self.client.post(redeem_url) + self.assertEquals(response.status_code, 200) + + task_input = {'features': []} + with patch('instructor_task.tasks_helper._get_current_task'): + result = upload_enrollment_report(None, None, self.course.id, task_input, 'generating_enrollment_report') + self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) + self._verify_cell_data_in_csv(student.username, 'Enrollment Source', 'Used Registration Code') + self._verify_cell_data_in_csv(student.username, 'Payment Status', 'purchased') + + def test_student_used_invoice_unpaid_enrollment_code_for_course_enrollment(self): + """ + test to check the user enrollment source and payment status in the + enrollment detailed report + """ + student = UserFactory() + self.client.login(username=student.username, password='test') + + course_registration_code = CourseRegistrationCode( + code='abcde', + course_id=self.course.id.to_deprecated_string(), + created_by=self.instructor, + invoice=self.sale_invoice_1, + invoice_item=self.invoice_item, + mode_slug='honor' + ) + course_registration_code.save() + + redeem_url = reverse('register_code_redemption', args=['abcde']) + response = self.client.get(redeem_url) + self.assertEquals(response.status_code, 200) + # check button text + self.assertTrue('Activate Course Enrollment' in response.content) + + response = self.client.post(redeem_url) + self.assertEquals(response.status_code, 200) + + task_input = {'features': []} + with patch('instructor_task.tasks_helper._get_current_task'): + result = upload_enrollment_report(None, None, self.course.id, task_input, 'generating_enrollment_report') + self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) + self._verify_cell_data_in_csv(student.username, 'Enrollment Source', 'Used Registration Code') + self._verify_cell_data_in_csv(student.username, 'Payment Status', 'Invoice Outstanding') + + def test_student_used_invoice_paid_enrollment_code_for_course_enrollment(self): + """ + test to check the user enrollment source and payment status in the + enrollment detailed report + """ + student = UserFactory() + self.client.login(username=student.username, password='test') + invoice_transaction = InvoiceTransaction( + invoice=self.sale_invoice_1, + amount=self.sale_invoice_1.total_amount, + status='completed', + created_by=self.instructor, + last_modified_by=self.instructor + ) + invoice_transaction.save() + course_registration_code = CourseRegistrationCode( + code='abcde', + course_id=self.course.id.to_deprecated_string(), + created_by=self.instructor, + invoice=self.sale_invoice_1, + invoice_item=self.invoice_item, + mode_slug='honor' + ) + course_registration_code.save() + + redeem_url = reverse('register_code_redemption', args=['abcde']) + response = self.client.get(redeem_url) + self.assertEquals(response.status_code, 200) + # check button text + self.assertTrue('Activate Course Enrollment' in response.content) + + response = self.client.post(redeem_url) + self.assertEquals(response.status_code, 200) + + task_input = {'features': []} + with patch('instructor_task.tasks_helper._get_current_task'): + result = upload_enrollment_report(None, None, self.course.id, task_input, 'generating_enrollment_report') + self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) + self._verify_cell_data_in_csv(student.username, 'Enrollment Source', 'Used Registration Code') + self._verify_cell_data_in_csv(student.username, 'Payment Status', 'Invoice Paid') + + def _verify_cell_data_in_csv(self, username, column_header, expected_cell_content): + """ + Verify that the last ReportStore CSV contains the expected content. + """ + report_store = ReportStore.from_config(config_name='FINANCIAL_REPORTS') + report_csv_filename = report_store.links_for(self.course.id)[0][0] + with open(report_store.path_to(self.course.id, report_csv_filename)) as csv_file: + # Expand the dict reader generator so we don't lose it's content + for row in unicodecsv.DictReader(csv_file): + if row.get('Username') == username: + self.assertEqual(row[column_header], expected_cell_content) + + class TestProblemGradeReport(TestReportMixin, InstructorTaskModuleTestCase): """ Test that the problem CSV generation works. @@ -347,7 +524,7 @@ class TestProblemGradeReport(TestReportMixin, InstructorTaskModuleTestCase): result = upload_problem_grade_report(None, None, self.course.id, None, 'graded') self.assertDictContainsSubset({'attempted': 1, 'succeeded': 0, 'failed': 1}, result) - report_store = ReportStore.from_config() + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') self.assertTrue(any('grade_report_err' in item[0] for item in report_store.links_for(self.course.id))) self.verify_rows_in_csv([ { @@ -521,7 +698,7 @@ class TestStudentReport(TestReportMixin, InstructorTaskCourseTestCase): task_input = {'features': []} with patch('instructor_task.tasks_helper._get_current_task'): result = upload_students_csv(None, None, self.course.id, task_input, 'calculated') - report_store = ReportStore.from_config() + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') links = report_store.links_for(self.course.id) self.assertEquals(len(links), 1) @@ -842,7 +1019,7 @@ class TestGradeReportEnrollmentAndCertificateInfo(TestReportMixin, InstructorTas """ with patch('instructor_task.tasks_helper._get_current_task'): upload_grades_csv(None, None, self.course.id, None, 'graded') - report_store = ReportStore.from_config() + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') report_csv_filename = report_store.links_for(self.course.id)[0][0] with open(report_store.path_to(self.course.id, report_csv_filename)) as csv_file: for row in unicodecsv.DictReader(csv_file): diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index a35f4c720f..705de7d908 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -7,6 +7,7 @@ from decimal import Decimal import json import analytics from io import BytesIO +from django.db.models import Q import pytz import logging import smtplib @@ -980,6 +981,17 @@ class InvoiceTransaction(TimeStampedModel): created_by = models.ForeignKey(User) last_modified_by = models.ForeignKey(User, related_name='last_modified_by_user') + @classmethod + def get_invoice_transaction(cls, invoice_id): + """ + if found Returns the Invoice Transaction object for the given invoice_id + else returns None + """ + try: + return cls.objects.get(Q(invoice_id=invoice_id), Q(status='completed') | Q(status='refunded')) + except InvoiceTransaction.DoesNotExist: + return None + def snapshot(self): """Create a snapshot of the invoice transaction. @@ -1164,6 +1176,17 @@ class RegistrationCodeRedemption(models.Model): redeemed_at = models.DateTimeField(default=datetime.now(pytz.utc), null=True) course_enrollment = models.ForeignKey(CourseEnrollment, null=True) + @classmethod + def registration_code_used_for_enrollment(cls, course_enrollment): + """ + Returns RegistrationCodeRedemption object if registration code + has been used during the course enrollment else Returns None. + """ + try: + return cls.objects.get(course_enrollment=course_enrollment) + except RegistrationCodeRedemption.DoesNotExist: + return None + @classmethod def is_registration_code_redeemed(cls, course_reg_code): """ @@ -1296,6 +1319,18 @@ class PaidCourseRegistration(OrderItem): mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG) course_enrollment = models.ForeignKey(CourseEnrollment, null=True) + @classmethod + def get_course_item_for_user_enrollment(cls, user, course_id, course_enrollment): + """ + Returns PaidCourseRegistration object if user has payed for + the course enrollment else Returns None + """ + try: + return cls.objects.filter(course_id=course_id, user=user, course_enrollment=course_enrollment, + status='purchased').latest('id') + except PaidCourseRegistration.DoesNotExist: + return None + @classmethod def contained_in_order(cls, order, course_id): """ diff --git a/lms/djangoapps/shoppingcart/reports.py b/lms/djangoapps/shoppingcart/reports.py index 8be4d71e81..fd69b3b700 100644 --- a/lms/djangoapps/shoppingcart/reports.py +++ b/lms/djangoapps/shoppingcart/reports.py @@ -158,7 +158,7 @@ class CertificateStatusReport(Report): cur_course = get_course_by_id(course_id) university = cur_course.org course = cur_course.number + " " + cur_course.display_name_with_default # TODO add term (i.e. Fall 2013)? - counts = CourseEnrollment.enrollment_counts(course_id) + counts = CourseEnrollment.objects.enrollment_counts(course_id) total_enrolled = counts['total'] audit_enrolled = counts['audit'] honor_enrolled = counts['honor'] diff --git a/lms/envs/aws.py b/lms/envs/aws.py index c210ed1261..2740006fbc 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -478,6 +478,9 @@ GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE GRADES_DOWNLOAD = ENV_TOKENS.get("GRADES_DOWNLOAD", GRADES_DOWNLOAD) +# financial reports +FINANCIAL_REPORTS = ENV_TOKENS.get("FINANCIAL_REPORTS", FINANCIAL_REPORTS) + ##### ORA2 ###### # Prefix for uploads of example-based assessment AI classifiers # This can be used to separate uploads for different environments diff --git a/lms/envs/common.py b/lms/envs/common.py index 1586bd5519..e962d4e9dd 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1955,6 +1955,12 @@ GRADES_DOWNLOAD = { 'ROOT_PATH': '/tmp/edx-s3/grades', } +FINANCIAL_REPORTS = { + 'STORAGE_TYPE': 'localfs', + 'BUCKET': 'edx-financial-reports', + 'ROOT_PATH': '/tmp/edx-s3/financial_reports', +} + #### PASSWORD POLICY SETTINGS ##### PASSWORD_MIN_LENGTH = 8 diff --git a/lms/static/coffee/src/instructor_dashboard/data_download.coffee b/lms/static/coffee/src/instructor_dashboard/data_download.coffee index ccca75ba83..548222fa50 100644 --- a/lms/static/coffee/src/instructor_dashboard/data_download.coffee +++ b/lms/static/coffee/src/instructor_dashboard/data_download.coffee @@ -9,6 +9,7 @@ such that the value can be defined later than this assignment (file load order). # Load utilities std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks +ReportDownloads = -> window.InstructorDashboard.util.ReportDownloads # Data Download Section class DataDownload @@ -33,7 +34,7 @@ class DataDownload @$reports_request_response = @$reports.find '.request-response' @$reports_request_response_error = @$reports.find '.request-response-error' - @report_downloads = new ReportDownloads(@$section) + @report_downloads = new (ReportDownloads()) @$section @instructor_tasks = new (PendingInstructorTasks()) @$section @clear_display() @@ -153,66 +154,6 @@ class DataDownload $(".msg-confirm").css({"display":"none"}) $(".msg-error").css({"display":"none"}) - -class ReportDownloads - ### Report Downloads -- links expire quickly, so we refresh every 5 mins #### - constructor: (@$section) -> - - @$report_downloads_table = @$section.find ".report-downloads-table" - - POLL_INTERVAL = 20000 # 20 seconds, just like the "pending instructor tasks" table - @downloads_poller = new window.InstructorDashboard.util.IntervalManager( - POLL_INTERVAL, => @reload_report_downloads() - ) - - reload_report_downloads: -> - endpoint = @$report_downloads_table.data 'endpoint' - $.ajax - dataType: 'json' - url: endpoint - success: (data) => - if data.downloads.length - @create_report_downloads_table data.downloads - else - console.log "No reports ready for download" - error: (std_ajax_err) => console.error "Error finding report downloads" - - create_report_downloads_table: (report_downloads_data) -> - @$report_downloads_table.empty() - - options = - enableCellNavigation: true - enableColumnReorder: false - rowHeight: 30 - forceFitColumns: true - - columns = [ - id: 'link' - field: 'link' - name: gettext('File Name') - toolTip: gettext("Links are generated on demand and expire within 5 minutes due to the sensitive nature of student information.") - sortable: false - minWidth: 150 - cssClass: "file-download-link" - formatter: (row, cell, value, columnDef, dataContext) -> - '' + dataContext['name'] + '' - ] - - $table_placeholder = $ '
', class: 'slickgrid' - @$report_downloads_table.append $table_placeholder - grid = new Slick.Grid($table_placeholder, report_downloads_data, columns, options) - grid.onClick.subscribe( - (event) => - report_url = event.target.href - if report_url - # Record that the user requested to download a report - Logger.log('edx.instructor.report.downloaded', { - report_url: report_url - }) - ) - grid.autosizeColumns() - - # export for use # create parent namespaces if they do not already exist. _.defaults window, InstructorDashboard: {} diff --git a/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee b/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee index f41b6b0bce..f40c31ed89 100644 --- a/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee +++ b/lms/static/coffee/src/instructor_dashboard/e-commerce.coffee @@ -2,6 +2,10 @@ E-Commerce Section ### +# Load utilities +PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks +ReportDownloads = -> window.InstructorDashboard.util.ReportDownloads + class ECommerce # E-Commerce Section constructor: (@$section) -> @@ -20,6 +24,13 @@ class ECommerce @$active_registration_codes_form = @$section.find("form#active_registration_codes") @$spent_registration_codes_form = @$section.find("form#spent_registration_codes") + @$reports = @$section.find '.reports-download-container' + @$reports_request_response = @$reports.find '.request-response' + @$reports_request_response_error = @$reports.find '.request-response-error' + + @report_downloads = new (ReportDownloads()) @$section + @instructor_tasks = new (PendingInstructorTasks()) @$section + @$error_msg = @$section.find('#error-msg') # attach click handlers @@ -53,16 +64,20 @@ class ECommerce # handler for when the section title is clicked. onClickTitle: -> @clear_display() - - # handler for when the section title is clicked. - onClickTitle: -> @clear_display() + @instructor_tasks.task_poller.start() + @report_downloads.downloads_poller.start() # handler for when the section is closed - onExit: -> @clear_display() + onExit: -> + @clear_display() + @instructor_tasks.task_poller.stop() + @report_downloads.downloads_poller.stop() clear_display: -> @$error_msg.attr('style', 'display: none') @$download_company_name.val('') + @$reports_request_response.empty() + @$reports_request_response_error.empty() @$active_company_name.val('') @$spent_company_name.val('') diff --git a/lms/static/coffee/src/instructor_dashboard/util.coffee b/lms/static/coffee/src/instructor_dashboard/util.coffee index ae6e6e85a7..92b6d5489f 100644 --- a/lms/static/coffee/src/instructor_dashboard/util.coffee +++ b/lms/static/coffee/src/instructor_dashboard/util.coffee @@ -325,6 +325,66 @@ class KeywordValidator invalid_keywords: invalid_keywords } + +class ReportDownloads + ### Report Downloads -- links expire quickly, so we refresh every 5 mins #### + constructor: (@$section) -> + + @$report_downloads_table = @$section.find ".report-downloads-table" + + POLL_INTERVAL = 20000 # 20 seconds, just like the "pending instructor tasks" table + @downloads_poller = new window.InstructorDashboard.util.IntervalManager( + POLL_INTERVAL, => @reload_report_downloads() + ) + + reload_report_downloads: -> + endpoint = @$report_downloads_table.data 'endpoint' + $.ajax + dataType: 'json' + url: endpoint + success: (data) => + if data.downloads.length + @create_report_downloads_table data.downloads + else + console.log "No reports ready for download" + error: (std_ajax_err) => console.error "Error finding report downloads" + + create_report_downloads_table: (report_downloads_data) -> + @$report_downloads_table.empty() + + options = + enableCellNavigation: true + enableColumnReorder: false + rowHeight: 30 + forceFitColumns: true + + columns = [ + id: 'link' + field: 'link' + name: gettext('File Name') + toolTip: gettext("Links are generated on demand and expire within 5 minutes due to the sensitive nature of student information.") + sortable: false + minWidth: 150 + cssClass: "file-download-link" + formatter: (row, cell, value, columnDef, dataContext) -> + '' + dataContext['name'] + '' + ] + + $table_placeholder = $ '
', class: 'slickgrid' + @$report_downloads_table.append $table_placeholder + grid = new Slick.Grid($table_placeholder, report_downloads_data, columns, options) + grid.onClick.subscribe( + (event) => + report_url = event.target.href + if report_url + # Record that the user requested to download a report + Logger.log('edx.instructor.report.downloaded', { + report_url: report_url + }) + ) + grid.autosizeColumns() + + # export for use # create parent namespaces if they do not already exist. # abort if underscore can not be found. @@ -340,3 +400,4 @@ if _? create_email_message_views: create_email_message_views PendingInstructorTasks: PendingInstructorTasks KeywordValidator: KeywordValidator + ReportDownloads: ReportDownloads diff --git a/lms/static/js/instructor_dashboard/ecommerce.js b/lms/static/js/instructor_dashboard/ecommerce.js index 7b6f1c30ec..c301349fe4 100644 --- a/lms/static/js/instructor_dashboard/ecommerce.js +++ b/lms/static/js/instructor_dashboard/ecommerce.js @@ -1,6 +1,6 @@ var edx = edx || {}; -(function(Backbone, $, _) { +(function(Backbone, $, _, gettext) { 'use strict'; edx.instructor_dashboard = edx.instructor_dashboard || {}; @@ -31,5 +31,26 @@ var edx = edx || {}; minDate: 0 }); var view = new edx.instructor_dashboard.ecommerce.ExpiryCouponView(); + var request_response = $('.reports .request-response'); + var request_response_error = $('.reports .request-response-error'); + $('input[name="user-enrollment-report"]').click(function(){ + var url = $(this).data('endpoint'); + $.ajax({ + dataType: "json", + url: url, + success: function (data) { + request_response.text(data['status']); + return $(".reports .msg-confirm").css({ + "display": "block" + }); + }, + error: function(std_ajax_err) { + request_response_error.text(gettext('Error generating grades. Please try again.')); + return $(".reports .msg-error").css({ + "display": "block" + }); + } + }); + }); }); -}).call(this, Backbone, $, _); \ No newline at end of file +})(Backbone, $, _, gettext); \ No newline at end of file diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index f5ad96107a..028c43bd3c 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -1190,7 +1190,7 @@ // view - data download // -------------------- -.instructor-dashboard-wrapper-2 section.idash-section#data_download { +.instructor-dashboard-wrapper-2 section.idash-section#data_download{ input { margin-bottom: 1em; line-height: 1.3em; @@ -1452,6 +1452,23 @@ input[name="subject"] { margin-bottom: 1em; line-height: 1.3em; } + .reports-download-container { + .data-display-table { + .slickgrid { + height: 400px; + } + } + .report-downloads-table { + .slickgrid { + height: 300px; + padding: ($baseline/4); + } + // Disable horizontal scroll bar when grid only has 1 column. Remove this CSS class when more columns added. + .slick-viewport { + overflow-x: hidden !important; + } + } + } .error-msgs { background: #FFEEF5; color:#B72667; diff --git a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html index d2bc3eb922..9caf18669c 100644 --- a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html +++ b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html @@ -84,6 +84,45 @@
%endif + %if section_data['reports_enabled']: +
+

${_("Reports")}

+
+ +
+

${_("Download a .csv file for all credit card purchases or for all invoices, regardless of status")}

+ +
+
+
+
+
+
+

${_("Reports Available for Download")}

+

${_("The reports listed below are available for download. A link to every report remains available on this page, identified by the UTC date and time of generation. Reports are not deleted, so you will always be able to access previously generated reports from this page.")}

+ + ## Translators: a table of URL links to report files appears after this sentence. +

${_("Note: To keep student data secure, you cannot save or email these links for direct access. Copies of links expire within 5 minutes.")}


+ +
+
+ + %if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'): +
+
+

${_("Pending Instructor Tasks")}

+
+

${_("The status for any active tasks appears in a table below.")}

+
+
+
+
+
+ %endif +
+
+ %endif %if section_data['coupons_enabled']:

${_("Coupons List")}

@@ -393,7 +432,7 @@ generate_registration_button.removeAttr('disabled'); return false; } - var modal_overLay = $('#lean_overlay') + var modal_overLay = $('#lean_overlay'); var registration_code_modal = $('#registration_code_generation_modal'); registration_code_modal.hide(); modal_overLay.hide(); diff --git a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html index 6148f285d3..0a8871f317 100644 --- a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html @@ -55,7 +55,6 @@ <%static:js group='application'/> ## Backbone classes declared explicitly until RequireJS is supported -