Commit 99de1c8f by chrisndodge

Merge pull request #7992 from edx/muhhshoaib/SOL-794-generate-detailed-enrollment-report

Muhhshoaib/sol 794 generate detailed enrollment report
parents 93178bc3 b555c869
...@@ -736,6 +736,66 @@ class AlreadyEnrolledError(CourseEnrollmentException): ...@@ -736,6 +736,66 @@ class AlreadyEnrolledError(CourseEnrollmentException):
pass 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): class CourseEnrollment(models.Model):
""" """
Represents a Student's Enrollment record for a single Course. You should Represents a Student's Enrollment record for a single Course. You should
...@@ -762,6 +822,8 @@ class CourseEnrollment(models.Model): ...@@ -762,6 +822,8 @@ class CourseEnrollment(models.Model):
# list of possible values. # list of possible values.
mode = models.CharField(default="honor", max_length=100) mode = models.CharField(default="honor", max_length=100)
objects = CourseEnrollmentManager()
class Meta: class Meta:
unique_together = (('user', 'course_id'),) unique_together = (('user', 'course_id'),)
ordering = ('user', 'course_id') ordering = ('user', 'course_id')
...@@ -832,17 +894,6 @@ class CourseEnrollment(models.Model): ...@@ -832,17 +894,6 @@ class CourseEnrollment(models.Model):
return None return None
@classmethod @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): def is_enrollment_closed(cls, user, course):
""" """
Returns a boolean value regarding whether the user has access to enroll in the course. Returns False if the Returns a boolean value regarding whether the user has access to enroll in the course. Returns False if the
...@@ -853,17 +904,6 @@ class CourseEnrollment(models.Model): ...@@ -853,17 +904,6 @@ class CourseEnrollment(models.Model):
from courseware.access import has_access # pylint: disable=import-error from courseware.access import has_access # pylint: disable=import-error
return not has_access(user, 'enroll', course) 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): def update_enrollment(self, mode=None, is_active=None, skip_refund=False):
""" """
Updates an enrollment for a user in a class. This includes options Updates an enrollment for a user in a class. This includes options
...@@ -1015,7 +1055,7 @@ class CourseEnrollment(models.Model): ...@@ -1015,7 +1055,7 @@ class CourseEnrollment(models.Model):
) )
raise EnrollmentClosedError raise EnrollmentClosedError
if CourseEnrollment.is_course_full(course): if CourseEnrollment.objects.is_course_full(course):
log.warning( log.warning(
u"User %s failed to enroll in full course %s", u"User %s failed to enroll in full course %s",
user.username, user.username,
...@@ -1187,30 +1227,6 @@ class CourseEnrollment(models.Model): ...@@ -1187,30 +1227,6 @@ class CourseEnrollment(models.Model):
def enrollments_for_user(cls, user): def enrollments_for_user(cls, user):
return CourseEnrollment.objects.filter(user=user, is_active=1) 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): def is_paid_course(self):
""" """
Returns True, if course is paid Returns True, if course is paid
......
...@@ -857,7 +857,7 @@ def course_about(request, course_id): ...@@ -857,7 +857,7 @@ def course_about(request, course_id):
# Used to provide context to message to student if enrollment not allowed # Used to provide context to message to student if enrollment not allowed
can_enroll = has_access(request.user, 'enroll', course) can_enroll = has_access(request.user, 'enroll', course)
invitation_only = course.invitation_only 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: # Register button should be disabled if one of the following is true:
# - Student is already registered for course # - Student is already registered for course
......
"""
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()
...@@ -55,7 +55,7 @@ class Command(BaseCommand): ...@@ -55,7 +55,7 @@ class Command(BaseCommand):
return return
try: 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()) print "Total students enrolled in {0}: {1}".format(course_id, enrolled_students.count())
calculate_task_statistics(enrolled_students, course, usage_key, task_number) calculate_task_statistics(enrolled_students, course, usage_key, task_number)
......
"""
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
"""
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)
...@@ -30,7 +30,7 @@ import unicodecsv ...@@ -30,7 +30,7 @@ import unicodecsv
import urllib import urllib
import decimal import decimal
from student import auth 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.file import store_uploaded_file, course_and_time_based_filename_generator, FileValidationException, UniversalNewlineIterator
from util.json_request import JsonResponse from util.json_request import JsonResponse
from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features
...@@ -277,6 +277,31 @@ def require_sales_admin(func): ...@@ -277,6 +277,31 @@ def require_sales_admin(func):
return wrapped 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 EMAIL_INDEX = 0
USERNAME_INDEX = 1 USERNAME_INDEX = 1
NAME_INDEX = 2 NAME_INDEX = 2
...@@ -1092,6 +1117,29 @@ def get_coupon_codes(request, course_id): # pylint: disable=unused-argument ...@@ -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) 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): 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 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): ...@@ -1918,7 +1966,27 @@ def list_report_downloads(_request, course_id):
List grade CSV files that are available for download for this course. List grade CSV files that are available for download for this course.
""" """
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) 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='<a href="{}">{}</a>'.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 = { response_payload = {
'downloads': [ 'downloads': [
......
...@@ -90,6 +90,10 @@ urlpatterns = patterns( ...@@ -90,6 +90,10 @@ urlpatterns = patterns(
url(r'problem_grade_report$', url(r'problem_grade_report$',
'instructor.views.api.problem_grade_report', name="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.. # Registration Codes..
url(r'get_registration_codes$', url(r'get_registration_codes$',
'instructor.views.api.get_registration_codes', name="get_registration_codes"), 'instructor.views.api.get_registration_codes', name="get_registration_codes"),
...@@ -100,6 +104,11 @@ urlpatterns = patterns( ...@@ -100,6 +104,11 @@ urlpatterns = patterns(
url(r'spent_registration_codes$', url(r'spent_registration_codes$',
'instructor.views.api.spent_registration_codes', name="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.. # Coupon Codes..
url(r'get_coupon_codes', url(r'get_coupon_codes',
'instructor.views.api.get_coupon_codes', name="get_coupon_codes"), 'instructor.views.api.get_coupon_codes', name="get_coupon_codes"),
......
...@@ -107,7 +107,7 @@ def instructor_dashboard_2(request, course_id): ...@@ -107,7 +107,7 @@ def instructor_dashboard_2(request, course_id):
# Gate access to Ecommerce tab # Gate access to Ecommerce tab
if course_mode_has_price and (access['finance_admin'] or access['sales_admin']): 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 # Certificates panel
# This is used to generate example certificates # This is used to generate example certificates
...@@ -150,7 +150,7 @@ def instructor_dashboard_2(request, course_id): ...@@ -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. ## 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 """ """ Provide data for the corresponding dashboard section """
course_key = course.id course_key = course.id
coupons = Coupon.objects.filter(course_id=course_key).order_by('-is_active') 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): ...@@ -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)}), '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)}), '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)}), '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, 'coupons': coupons,
'sales_admin': access['sales_admin'], 'sales_admin': access['sales_admin'],
'coupons_enabled': coupons_enabled, 'coupons_enabled': coupons_enabled,
'reports_enabled': reports_enabled,
'course_price': course_price, 'course_price': course_price,
'total_amount': total_amount 'total_amount': total_amount
} }
...@@ -291,7 +296,7 @@ def _section_course_info(course, access): ...@@ -291,7 +296,7 @@ def _section_course_info(course, access):
} }
if settings.FEATURES.get('DISPLAY_ANALYTICS_ENROLLMENTS'): 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: if settings.ANALYTICS_DASHBOARD_URL:
dashboard_link = _get_dashboard_link(course_key) dashboard_link = _get_dashboard_link(course_key)
...@@ -356,7 +361,7 @@ def _section_cohort_management(course, access): ...@@ -356,7 +361,7 @@ def _section_cohort_management(course, access):
def _is_small_course(course_key): def _is_small_course(course_key):
""" Compares against MAX_ENROLLMENT_INSTR_BUTTONS to determine if course enrollment is considered small. """ """ Compares against MAX_ENROLLMENT_INSTR_BUTTONS to determine if course enrollment is considered small. """
is_small_course = False 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") max_enrollment_for_buttons = settings.FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
if max_enrollment_for_buttons is not None: if max_enrollment_for_buttons is not None:
is_small_course = enrollment_count <= max_enrollment_for_buttons is_small_course = enrollment_count <= max_enrollment_for_buttons
......
...@@ -102,7 +102,7 @@ def instructor_dashboard(request, course_id): ...@@ -102,7 +102,7 @@ def instructor_dashboard(request, course_id):
else: else:
idash_mode = request.session.get(idash_mode_key, 'Grades') 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 # assemble some course statistics for output to instructor
def get_course_stats_table(): def get_course_stats_table():
......
...@@ -22,7 +22,7 @@ from instructor_task.tasks import ( ...@@ -22,7 +22,7 @@ from instructor_task.tasks import (
calculate_problem_grade_report, calculate_problem_grade_report,
calculate_students_features_csv, calculate_students_features_csv,
cohort_students, cohort_students,
) enrollment_report_features_csv)
from instructor_task.api_helper import ( from instructor_task.api_helper import (
check_arguments_for_rescoring, check_arguments_for_rescoring,
...@@ -361,6 +361,20 @@ def submit_calculate_students_features_csv(request, course_key, features): ...@@ -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) 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): def submit_cohort_students(request, course_key, file_name):
""" """
Request to have students cohorted in bulk. Request to have students cohorted in bulk.
......
...@@ -198,16 +198,16 @@ class ReportStore(object): ...@@ -198,16 +198,16 @@ class ReportStore(object):
passing in the whole dataset. Doing that for now just because it's simpler. passing in the whole dataset. Doing that for now just because it's simpler.
""" """
@classmethod @classmethod
def from_config(cls): def from_config(cls, config_name):
""" """
Return one of the ReportStore subclasses depending on django Return one of the ReportStore subclasses depending on django
configuration. Look at subclasses for expected configuration. 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": if storage_type.lower() == "s3":
return S3ReportStore.from_config() return S3ReportStore.from_config(config_name)
elif storage_type.lower() == "localfs": elif storage_type.lower() == "localfs":
return LocalFSReportStore.from_config() return LocalFSReportStore.from_config(config_name)
def _get_utf8_encoded_rows(self, rows): def _get_utf8_encoded_rows(self, rows):
""" """
...@@ -242,7 +242,7 @@ class S3ReportStore(ReportStore): ...@@ -242,7 +242,7 @@ class S3ReportStore(ReportStore):
self.bucket = conn.get_bucket(bucket_name) self.bucket = conn.get_bucket(bucket_name)
@classmethod @classmethod
def from_config(cls): def from_config(cls, config_name):
""" """
The expected configuration for an `S3ReportStore` is to have a The expected configuration for an `S3ReportStore` is to have a
`GRADES_DOWNLOAD` dict in settings with the following fields:: `GRADES_DOWNLOAD` dict in settings with the following fields::
...@@ -257,8 +257,8 @@ class S3ReportStore(ReportStore): ...@@ -257,8 +257,8 @@ class S3ReportStore(ReportStore):
and `AWS_SECRET_ACCESS_KEY` in settings. and `AWS_SECRET_ACCESS_KEY` in settings.
""" """
return cls( return cls(
settings.GRADES_DOWNLOAD['BUCKET'], getattr(settings, config_name).get("BUCKET"),
settings.GRADES_DOWNLOAD['ROOT_PATH'] getattr(settings, config_name).get("ROOT_PATH")
) )
def key_for(self, course_id, filename): def key_for(self, course_id, filename):
...@@ -354,7 +354,7 @@ class LocalFSReportStore(ReportStore): ...@@ -354,7 +354,7 @@ class LocalFSReportStore(ReportStore):
os.makedirs(root_path) os.makedirs(root_path)
@classmethod @classmethod
def from_config(cls): def from_config(cls, config_name):
""" """
Generate an instance of this object from Django settings. It assumes 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 that there is a dict in settings named GRADES_DOWNLOAD and that it has
...@@ -365,7 +365,7 @@ class LocalFSReportStore(ReportStore): ...@@ -365,7 +365,7 @@ class LocalFSReportStore(ReportStore):
STORAGE_TYPE : "localfs" STORAGE_TYPE : "localfs"
ROOT_PATH : /tmp/edx/report-downloads/ 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): def path_to(self, course_id, filename):
"""Return the full path to a given file for a given course.""" """Return the full path to a given file for a given course."""
......
...@@ -37,8 +37,8 @@ from instructor_task.tasks_helper import ( ...@@ -37,8 +37,8 @@ from instructor_task.tasks_helper import (
upload_grades_csv, upload_grades_csv,
upload_problem_grade_report, upload_problem_grade_report,
upload_students_csv, upload_students_csv,
cohort_students_and_upload cohort_students_and_upload,
) upload_enrollment_report)
TASK_LOG = logging.getLogger('edx.celery.task') TASK_LOG = logging.getLogger('edx.celery.task')
...@@ -185,6 +185,18 @@ def calculate_students_features_csv(entry_id, xmodule_instance_args): ...@@ -185,6 +185,18 @@ def calculate_students_features_csv(entry_id, xmodule_instance_args):
return run_main_task(entry_id, task_fn, action_name) 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 @task(base=BaseInstructorTask) # pylint: disable=E1102
def cohort_students(entry_id, xmodule_instance_args): def cohort_students(entry_id, xmodule_instance_args):
""" """
......
...@@ -19,12 +19,13 @@ from django.core.files.storage import DefaultStorage ...@@ -19,12 +19,13 @@ from django.core.files.storage import DefaultStorage
from django.db import transaction, reset_queries from django.db import transaction, reset_queries
import dogstats_wrapper as dog_stats_api import dogstats_wrapper as dog_stats_api
from pytz import UTC from pytz import UTC
from instructor.paidcourse_enrollment_report import PaidCourseEnrollmentReportProvider
from track.views import task_track from track.views import task_track
from util.file import course_filename_prefix_generator, UniversalNewlineIterator from util.file import course_filename_prefix_generator, UniversalNewlineIterator
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.split_test_module import get_split_user_partitions 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 certificates.models import CertificateWhitelist, certificate_info_for_user
from courseware.courses import get_course_by_id, get_problems_in_section from courseware.courses import get_course_by_id, get_problems_in_section
from courseware.grades import iterate_grades_for from courseware.grades import iterate_grades_for
...@@ -532,7 +533,7 @@ def delete_problem_module_state(xmodule_instance_args, _module_descriptor, stude ...@@ -532,7 +533,7 @@ def delete_problem_module_state(xmodule_instance_args, _module_descriptor, stude
return UPDATE_STATUS_SUCCEEDED 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. Upload data as a CSV using ReportStore.
...@@ -546,7 +547,7 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp): ...@@ -546,7 +547,7 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp):
csv_name: Name of the resulting CSV csv_name: Name of the resulting CSV
course_id: ID of the course course_id: ID of the course
""" """
report_store = ReportStore.from_config() report_store = ReportStore.from_config(config_name)
report_store.store_rows( report_store.store_rows(
course_id, course_id,
u"{course_prefix}_{csv_name}_{timestamp_str}.csv".format( 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, ...@@ -575,7 +576,7 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
start_time = time() start_time = time()
start_date = datetime.now(UTC) start_date = datetime.now(UTC)
status_interval = 100 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) 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}' 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 ...@@ -771,7 +772,7 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t
start_time = time() start_time = time()
start_date = datetime.now(UTC) start_date = datetime.now(UTC)
status_interval = 100 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) task_progress = TaskProgress(action_name, enrolled_students.count(), start_time)
# This struct encapsulates both the display names of each static item in the # 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 ...@@ -842,7 +843,9 @@ def upload_students_csv(_xmodule_instance_args, _entry_id, course_id, task_input
""" """
start_time = time() start_time = time()
start_date = datetime.now(UTC) 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'} current_step = {'step': 'Calculating Profile Info'}
task_progress.update_task_state(extra_meta=current_step) 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 ...@@ -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) 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): 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 Within a given course, cohort students in bulk, then upload the results
......
...@@ -16,7 +16,7 @@ from instructor_task.api import ( ...@@ -16,7 +16,7 @@ from instructor_task.api import (
submit_bulk_course_email, submit_bulk_course_email,
submit_calculate_students_features_csv, submit_calculate_students_features_csv,
submit_cohort_students, submit_cohort_students,
) submit_detailed_enrollment_features_csv)
from instructor_task.api_helper import AlreadyRunningError from instructor_task.api_helper import AlreadyRunningError
from instructor_task.models import InstructorTask, PROGRESS from instructor_task.models import InstructorTask, PROGRESS
...@@ -207,6 +207,11 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa ...@@ -207,6 +207,11 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
) )
self._test_resubmission(api_call) 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): def test_submit_cohort_students(self):
api_call = lambda: submit_cohort_students( api_call = lambda: submit_cohort_students(
self.create_task_request(self.instructor), self.create_task_request(self.instructor),
......
...@@ -314,7 +314,7 @@ class TestReportMixin(object): ...@@ -314,7 +314,7 @@ class TestReportMixin(object):
ignore_other_columns (boolean): When True, we verify that `expected_rows` ignore_other_columns (boolean): When True, we verify that `expected_rows`
contain data which is the subset of actual csv 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] 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: 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 # Expand the dict reader generator so we don't lose it's content
......
...@@ -91,7 +91,7 @@ class LocalFSReportStoreTestCase(ReportStoreTestMixin, TestReportMixin, TestCase ...@@ -91,7 +91,7 @@ class LocalFSReportStoreTestCase(ReportStoreTestMixin, TestReportMixin, TestCase
""" """
def create_report_store(self): def create_report_store(self):
""" Create and return a LocalFSReportStore. """ """ 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) @mock.patch('instructor_task.models.S3Connection', new=MockS3Connection)
...@@ -104,4 +104,4 @@ class S3ReportStoreTestCase(ReportStoreTestMixin, TestReportMixin, TestCase): ...@@ -104,4 +104,4 @@ class S3ReportStoreTestCase(ReportStoreTestMixin, TestReportMixin, TestCase):
""" """
def create_report_store(self): def create_report_store(self):
""" Create and return a S3ReportStore. """ """ Create and return a S3ReportStore. """
return S3ReportStore.from_config() return S3ReportStore.from_config(config_name='GRADES_DOWNLOAD')
...@@ -7,6 +7,7 @@ from decimal import Decimal ...@@ -7,6 +7,7 @@ from decimal import Decimal
import json import json
import analytics import analytics
from io import BytesIO from io import BytesIO
from django.db.models import Q
import pytz import pytz
import logging import logging
import smtplib import smtplib
...@@ -983,6 +984,17 @@ class InvoiceTransaction(TimeStampedModel): ...@@ -983,6 +984,17 @@ class InvoiceTransaction(TimeStampedModel):
created_by = models.ForeignKey(User) created_by = models.ForeignKey(User)
last_modified_by = models.ForeignKey(User, related_name='last_modified_by_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): def snapshot(self):
"""Create a snapshot of the invoice transaction. """Create a snapshot of the invoice transaction.
...@@ -1168,6 +1180,17 @@ class RegistrationCodeRedemption(models.Model): ...@@ -1168,6 +1180,17 @@ class RegistrationCodeRedemption(models.Model):
course_enrollment = models.ForeignKey(CourseEnrollment, null=True) course_enrollment = models.ForeignKey(CourseEnrollment, null=True)
@classmethod @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): def is_registration_code_redeemed(cls, course_reg_code):
""" """
Checks the existence of the registration code Checks the existence of the registration code
...@@ -1324,6 +1347,18 @@ class PaidCourseRegistration(OrderItem): ...@@ -1324,6 +1347,18 @@ class PaidCourseRegistration(OrderItem):
course_enrollment = models.ForeignKey(CourseEnrollment, null=True) course_enrollment = models.ForeignKey(CourseEnrollment, null=True)
@classmethod @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): def contained_in_order(cls, order, course_id):
""" """
Is the course defined by course_id contained in the order? Is the course defined by course_id contained in the order?
......
...@@ -158,7 +158,7 @@ class CertificateStatusReport(Report): ...@@ -158,7 +158,7 @@ class CertificateStatusReport(Report):
cur_course = get_course_by_id(course_id) cur_course = get_course_by_id(course_id)
university = cur_course.org university = cur_course.org
course = cur_course.number + " " + cur_course.display_name_with_default # TODO add term (i.e. Fall 2013)? 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'] total_enrolled = counts['total']
audit_enrolled = counts['audit'] audit_enrolled = counts['audit']
honor_enrolled = counts['honor'] honor_enrolled = counts['honor']
......
...@@ -478,6 +478,9 @@ GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE ...@@ -478,6 +478,9 @@ GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE
GRADES_DOWNLOAD = ENV_TOKENS.get("GRADES_DOWNLOAD", GRADES_DOWNLOAD) GRADES_DOWNLOAD = ENV_TOKENS.get("GRADES_DOWNLOAD", GRADES_DOWNLOAD)
# financial reports
FINANCIAL_REPORTS = ENV_TOKENS.get("FINANCIAL_REPORTS", FINANCIAL_REPORTS)
##### ORA2 ###### ##### ORA2 ######
# Prefix for uploads of example-based assessment AI classifiers # Prefix for uploads of example-based assessment AI classifiers
# This can be used to separate uploads for different environments # This can be used to separate uploads for different environments
......
...@@ -1959,6 +1959,12 @@ GRADES_DOWNLOAD = { ...@@ -1959,6 +1959,12 @@ GRADES_DOWNLOAD = {
'ROOT_PATH': '/tmp/edx-s3/grades', '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 POLICY SETTINGS #####
PASSWORD_MIN_LENGTH = 8 PASSWORD_MIN_LENGTH = 8
......
...@@ -9,6 +9,7 @@ such that the value can be defined later than this assignment (file load order). ...@@ -9,6 +9,7 @@ such that the value can be defined later than this assignment (file load order).
# Load utilities # Load utilities
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks
ReportDownloads = -> window.InstructorDashboard.util.ReportDownloads
# Data Download Section # Data Download Section
class DataDownload class DataDownload
...@@ -33,7 +34,7 @@ class DataDownload ...@@ -33,7 +34,7 @@ class DataDownload
@$reports_request_response = @$reports.find '.request-response' @$reports_request_response = @$reports.find '.request-response'
@$reports_request_response_error = @$reports.find '.request-response-error' @$reports_request_response_error = @$reports.find '.request-response-error'
@report_downloads = new ReportDownloads(@$section) @report_downloads = new (ReportDownloads()) @$section
@instructor_tasks = new (PendingInstructorTasks()) @$section @instructor_tasks = new (PendingInstructorTasks()) @$section
@clear_display() @clear_display()
...@@ -153,66 +154,6 @@ class DataDownload ...@@ -153,66 +154,6 @@ class DataDownload
$(".msg-confirm").css({"display":"none"}) $(".msg-confirm").css({"display":"none"})
$(".msg-error").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) ->
'<a href="' + dataContext['url'] + '">' + dataContext['name'] + '</a>'
]
$table_placeholder = $ '<div/>', 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 # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
_.defaults window, InstructorDashboard: {} _.defaults window, InstructorDashboard: {}
......
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
E-Commerce Section E-Commerce Section
### ###
# Load utilities
PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks
ReportDownloads = -> window.InstructorDashboard.util.ReportDownloads
class ECommerce class ECommerce
# E-Commerce Section # E-Commerce Section
constructor: (@$section) -> constructor: (@$section) ->
...@@ -20,6 +24,13 @@ class ECommerce ...@@ -20,6 +24,13 @@ class ECommerce
@$active_registration_codes_form = @$section.find("form#active_registration_codes") @$active_registration_codes_form = @$section.find("form#active_registration_codes")
@$spent_registration_codes_form = @$section.find("form#spent_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') @$error_msg = @$section.find('#error-msg')
# attach click handlers # attach click handlers
...@@ -53,16 +64,20 @@ class ECommerce ...@@ -53,16 +64,20 @@ class ECommerce
# handler for when the section title is clicked. # handler for when the section title is clicked.
onClickTitle: -> onClickTitle: ->
@clear_display() @clear_display()
@instructor_tasks.task_poller.start()
# handler for when the section title is clicked. @report_downloads.downloads_poller.start()
onClickTitle: -> @clear_display()
# handler for when the section is closed # 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: -> clear_display: ->
@$error_msg.attr('style', 'display: none') @$error_msg.attr('style', 'display: none')
@$download_company_name.val('') @$download_company_name.val('')
@$reports_request_response.empty()
@$reports_request_response_error.empty()
@$active_company_name.val('') @$active_company_name.val('')
@$spent_company_name.val('') @$spent_company_name.val('')
......
...@@ -325,6 +325,66 @@ class KeywordValidator ...@@ -325,6 +325,66 @@ class KeywordValidator
invalid_keywords: invalid_keywords 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) ->
'<a href="' + dataContext['url'] + '">' + dataContext['name'] + '</a>'
]
$table_placeholder = $ '<div/>', 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 # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
# abort if underscore can not be found. # abort if underscore can not be found.
...@@ -340,3 +400,4 @@ if _? ...@@ -340,3 +400,4 @@ if _?
create_email_message_views: create_email_message_views create_email_message_views: create_email_message_views
PendingInstructorTasks: PendingInstructorTasks PendingInstructorTasks: PendingInstructorTasks
KeywordValidator: KeywordValidator KeywordValidator: KeywordValidator
ReportDownloads: ReportDownloads
var edx = edx || {}; var edx = edx || {};
(function(Backbone, $, _) { (function(Backbone, $, _, gettext) {
'use strict'; 'use strict';
edx.instructor_dashboard = edx.instructor_dashboard || {}; edx.instructor_dashboard = edx.instructor_dashboard || {};
...@@ -31,5 +31,26 @@ var edx = edx || {}; ...@@ -31,5 +31,26 @@ var edx = edx || {};
minDate: 0 minDate: 0
}); });
var view = new edx.instructor_dashboard.ecommerce.ExpiryCouponView(); 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, $, _); })(Backbone, $, _, gettext);
\ No newline at end of file \ No newline at end of file
...@@ -1190,7 +1190,7 @@ ...@@ -1190,7 +1190,7 @@
// view - data download // view - data download
// -------------------- // --------------------
.instructor-dashboard-wrapper-2 section.idash-section#data_download { .instructor-dashboard-wrapper-2 section.idash-section#data_download{
input { input {
margin-bottom: 1em; margin-bottom: 1em;
line-height: 1.3em; line-height: 1.3em;
...@@ -1452,6 +1452,23 @@ input[name="subject"] { ...@@ -1452,6 +1452,23 @@ input[name="subject"] {
margin-bottom: 1em; margin-bottom: 1em;
line-height: 1.3em; 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 { .error-msgs {
background: #FFEEF5; background: #FFEEF5;
color:#B72667; color:#B72667;
......
...@@ -84,6 +84,45 @@ ...@@ -84,6 +84,45 @@
</div> </div>
</div><!-- end wrap --> </div><!-- end wrap -->
%endif %endif
%if section_data['reports_enabled']:
<div class="reports wrap">
<h2>${_("Reports")}</h2>
<div>
<span class="csv_tip">
<div>
<p>${_("Download a .csv file for all credit card purchases or for all invoices, regardless of status")}</p>
<input type="button" class="add blue-button" name="user-enrollment-report" value="${_("Download Enrollment Report")}" data-endpoint="${ section_data['enrollment_report_url'] }">
</div>
<div class="request-response msg msg-confirm copy" id="report-request-response"></div>
<div class="request-response-error msg msg-warning copy" id="report-request-response-error"></div>
<br>
</span>
<div class="reports-download-container action-type-container">
<p><b>${_("Reports Available for Download")}</b></p>
<p>${_("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.")}</p>
## Translators: a table of URL links to report files appears after this sentence.
<p>${_("<b>Note</b>: To keep student data secure, you cannot save or email these links for direct access. Copies of links expire within 5 minutes.")}</p><br>
<div class="report-downloads-table" id="report-downloads-table"
data-endpoint="${ section_data['list_financial_report_downloads_url'] }"></div>
</div>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="running-tasks-container action-type-container">
<hr>
<h2> ${_("Pending Instructor Tasks")} </h2>
<div class="running-tasks-section">
<p>${_("The status for any active tasks appears in a table below.")} </p>
<br/>
<div class="running-tasks-table" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></div>
</div>
<div class="no-pending-tasks-message"></div>
</div>
%endif
</div>
</div><!-- end wrap -->
%endif
%if section_data['coupons_enabled']: %if section_data['coupons_enabled']:
<div class="wrap"> <div class="wrap">
<h2>${_("Coupons List")}</h2> <h2>${_("Coupons List")}</h2>
...@@ -393,7 +432,7 @@ ...@@ -393,7 +432,7 @@
generate_registration_button.removeAttr('disabled'); generate_registration_button.removeAttr('disabled');
return false; return false;
} }
var modal_overLay = $('#lean_overlay') var modal_overLay = $('#lean_overlay');
var registration_code_modal = $('#registration_code_generation_modal'); var registration_code_modal = $('#registration_code_generation_modal');
registration_code_modal.hide(); registration_code_modal.hide();
modal_overLay.hide(); modal_overLay.hide();
......
...@@ -55,7 +55,6 @@ ...@@ -55,7 +55,6 @@
<%static:js group='application'/> <%static:js group='application'/>
## Backbone classes declared explicitly until RequireJS is supported ## Backbone classes declared explicitly until RequireJS is supported
<script type="text/javascript" src="${static.url('js/instructor_dashboard/ecommerce.js')}"></script>
<script type="text/javascript" src="${static.url('js/instructor_dashboard/cohort_management.js')}"></script> <script type="text/javascript" src="${static.url('js/instructor_dashboard/cohort_management.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/notification.js')}"></script> <script type="text/javascript" src="${static.url('js/models/notification.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/notification.js')}"></script> <script type="text/javascript" src="${static.url('js/views/notification.js')}"></script>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment