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
This commit is contained in:
committed by
Chris Dodge
parent
d94c0ab1af
commit
b555c869bf
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
98
lms/djangoapps/instructor/enrollment_report.py
Normal file
98
lms/djangoapps/instructor/enrollment_report.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
178
lms/djangoapps/instructor/paidcourse_enrollment_report.py
Normal file
178
lms/djangoapps/instructor/paidcourse_enrollment_report.py
Normal file
@@ -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
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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)
|
||||
@@ -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='<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 = {
|
||||
'downloads': [
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ->
|
||||
'<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
|
||||
# create parent namespaces if they do not already exist.
|
||||
_.defaults window, InstructorDashboard: {}
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
@@ -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) ->
|
||||
'<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
|
||||
# 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
|
||||
|
||||
@@ -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, $, _);
|
||||
})(Backbone, $, _, gettext);
|
||||
@@ -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;
|
||||
|
||||
@@ -84,6 +84,45 @@
|
||||
</div>
|
||||
</div><!-- end wrap -->
|
||||
%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']:
|
||||
<div class="wrap">
|
||||
<h2>${_("Coupons List")}</h2>
|
||||
@@ -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();
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
<%static:js group='application'/>
|
||||
|
||||
## 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/models/notification.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/notification.js')}"></script>
|
||||
|
||||
Reference in New Issue
Block a user