Commit 0569a770 by Afzal Wali Committed by Chris Dodge

Executive Summary Report

parent 40b4bc65
......@@ -105,6 +105,16 @@ REPORTS_DATA = (
}
)
# ddt data for test cases involving executive summary report
EXECUTIVE_SUMMARY_DATA = (
{
'report_type': 'executive summary',
'instructor_api_endpoint': 'get_exec_summary_report',
'task_api_endpoint': 'instructor_task.api.submit_executive_summary_report',
'extra_instructor_api_kwargs': {}
},
)
@common_exceptions_400
def view_success(request): # pylint: disable=unused-argument
......@@ -215,6 +225,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
('get_students_features', {}),
('get_enrollment_report', {}),
('get_students_who_may_enroll', {}),
('get_exec_summary_report', {}),
]
# Endpoints that only Instructors can access
self.instructor_level_endpoints = [
......@@ -2544,9 +2555,36 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
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)
self.assertIn(success_status, response.content)
@ddt.data(*REPORTS_DATA)
@ddt.data(*EXECUTIVE_SUMMARY_DATA)
@ddt.unpack
def test_executive_summary_report_success(
self,
report_type,
instructor_api_endpoint,
task_api_endpoint,
extra_instructor_api_kwargs
):
kwargs = {'course_id': unicode(self.course.id)}
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 created." \
" To view the status of the report, see the 'Pending Instructor Tasks'" \
" section.".format(report_type=report_type)
self.assertIn(success_status, response.content)
@ddt.data(*EXECUTIVE_SUMMARY_DATA)
@ddt.unpack
def test_calculate_report_csv_already_running(self, report_type, instructor_api_endpoint, task_api_endpoint, extra_instructor_api_kwargs):
def test_executive_summary_report_already_running(
self,
report_type,
instructor_api_endpoint,
task_api_endpoint,
extra_instructor_api_kwargs
):
kwargs = {'course_id': unicode(self.course.id)}
kwargs.update(extra_instructor_api_kwargs)
url = reverse(instructor_api_endpoint, kwargs=kwargs)
......@@ -2555,7 +2593,11 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
with patch(task_api_endpoint) as mock:
mock.side_effect = AlreadyRunningError()
response = self.client.get(url, {})
already_running_status = "{report_type} 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.".format(report_type=report_type)
already_running_status = "An {report_type} report is currently in progress." \
" To view the status of the report, see the 'Pending Instructor Tasks' section." \
" When completed, the report will be available for download in the table below." \
" You will be able to download the" \
" report when it is complete.".format(report_type=report_type)
self.assertIn(already_running_status, response.content)
def test_get_distribution_no_feature(self):
......
......@@ -1228,6 +1228,31 @@ def get_enrollment_report(request, course_id):
})
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_finance_admin
def get_exec_summary_report(request, course_id):
"""
get the executive summary report for the particular course.
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
try:
instructor_task.api.submit_executive_summary_report(request, course_key)
status_response = _("Your executive summary report is being created. "
"To view the status of the report, see the 'Pending Instructor Tasks' section.")
except AlreadyRunningError:
status_response = _(
"An executive summary report is currently in progress. "
"To view the status of the report, see the 'Pending Instructor Tasks' section. "
"When completed, the report will be available for download in the table below. "
"You will be able to download the report when it is complete."
)
return JsonResponse({
"status": status_response
})
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
......
......@@ -109,7 +109,8 @@ urlpatterns = patterns(
# Reports..
url(r'get_enrollment_report$',
'instructor.views.api.get_enrollment_report', name="get_enrollment_report"),
url(r'get_exec_summary_report$',
'instructor.views.api.get_exec_summary_report', name="get_exec_summary_report"),
# Coupon Codes..
url(r'get_coupon_codes',
......
......@@ -202,6 +202,7 @@ def _section_e_commerce(course, access, paid_mode, coupons_enabled, reports_enab
'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)}),
'exec_summary_report_url': reverse('get_exec_summary_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)}),
......
......@@ -24,6 +24,7 @@ from instructor_task.tasks import (
cohort_students,
enrollment_report_features_csv,
calculate_may_enroll_csv,
exec_summary_report_csv
)
from instructor_task.api_helper import (
......@@ -392,6 +393,20 @@ def submit_calculate_may_enroll_csv(request, course_key, features):
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
def submit_executive_summary_report(request, course_key): # pylint: disable=invalid-name
"""
Submits a task to generate a HTML File containing the executive summary report.
Raises AlreadyRunningError if HTML File is already being updated.
"""
task_type = 'exec_summary_report'
task_class = exec_summary_report_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.
......
......@@ -275,7 +275,7 @@ class S3ReportStore(ReportStore):
return key
def store(self, course_id, filename, buff):
def store(self, course_id, filename, buff, config=None):
"""
Store the contents of `buff` in a directory determined by hashing
`course_id`, and name the file `filename`. `buff` is typically a
......@@ -288,10 +288,15 @@ class S3ReportStore(ReportStore):
"""
key = self.key_for(course_id, filename)
_config = config if config else {}
content_type = _config.get('content_type', 'text/csv')
content_encoding = _config.get('content_encoding', 'gzip')
data = buff.getvalue()
key.size = len(data)
key.content_encoding = "gzip"
key.content_type = "text/csv"
key.content_encoding = content_encoding
key.content_type = content_type
# Just setting the content encoding and type above should work
# according to the docs, but when experimenting, this was necessary for
......@@ -299,9 +304,9 @@ class S3ReportStore(ReportStore):
key.set_contents_from_string(
data,
headers={
"Content-Encoding": "gzip",
"Content-Encoding": content_encoding,
"Content-Length": len(data),
"Content-Type": "text/csv",
"Content-Type": content_type,
}
)
......@@ -371,7 +376,7 @@ class LocalFSReportStore(ReportStore):
"""Return the full path to a given file for a given course."""
return os.path.join(self.root_path, urllib.quote(course_id.to_deprecated_string(), safe=''), filename)
def store(self, course_id, filename, buff):
def store(self, course_id, filename, buff, config=None): # pylint: disable=unused-argument
"""
Given the `course_id` and `filename`, store the contents of `buff` in
that file. Overwrite anything that was there previously. `buff` is
......
......@@ -40,6 +40,7 @@ from instructor_task.tasks_helper import (
cohort_students_and_upload,
upload_enrollment_report,
upload_may_enroll_csv,
upload_exec_summary_report
)
......@@ -200,6 +201,18 @@ def enrollment_report_features_csv(entry_id, xmodule_instance_args):
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable
def exec_summary_report_csv(entry_id, xmodule_instance_args):
"""
Compute executive summary report for a course and upload the
Html generated report to an S3 bucket for download.
"""
# Translators: This is a past-tense verb that is inserted into task progress messages as {action}.
action_name = 'generating_exec_summary_report'
task_fn = partial(upload_exec_summary_report, 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 calculate_may_enroll_csv(entry_id, xmodule_instance_args):
"""
Compute information about invited students who have not enrolled
......
......@@ -6,6 +6,7 @@ running state of a course.
import json
from collections import OrderedDict
from datetime import datetime
from django.conf import settings
from eventtracking import tracker
from itertools import chain
from time import time
......@@ -19,7 +20,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 StringIO import StringIO
from edxmako.shortcuts import render_to_string
from instructor.paidcourse_enrollment_report import PaidCourseEnrollmentReportProvider
from shoppingcart.models import (
PaidCourseRegistration, CourseRegCodeItem, InvoiceTransaction,
Invoice, CouponRedemption, RegistrationCodeRedemption, CourseRegistrationCode
)
from track.views import task_track
from util.file import course_filename_prefix_generator, UniversalNewlineIterator
......@@ -41,7 +48,7 @@ from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from opaque_keys.edx.keys import UsageKey
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
from student.models import CourseEnrollment
from student.models import CourseEnrollment, CourseAccessRole
from verify_student.models import SoftwareSecurePhotoVerification
......@@ -50,7 +57,7 @@ TASK_LOG = logging.getLogger('edx.celery.task')
# define value to use when no task_id is provided:
UNKNOWN_TASK_ID = 'unknown-task_id'
FILTERED_OUT_ROLES = ['staff', 'instructor', 'finance_admin', 'sales_admin']
# define values for update functions to use to return status to perform_module_state_update
UPDATE_STATUS_SUCCEEDED = 'succeeded'
UPDATE_STATUS_FAILED = 'failed'
......@@ -560,6 +567,36 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp, config_name
tracker.emit(REPORT_REQUESTED_EVENT_NAME, {"report_type": csv_name, })
def upload_exec_summary_to_store(data_dict, report_name, course_id, generated_at, config_name='FINANCIAL_REPORTS'):
"""
Upload Executive Summary Html file using ReportStore.
Arguments:
data_dict: containing executive report data.
report_name: Name of the resulting Html File.
course_id: ID of the course
"""
report_store = ReportStore.from_config(config_name)
# Use the data dict and html template to generate the output buffer
output_buffer = StringIO(render_to_string("instructor/instructor_dashboard_2/executive_summary.html", data_dict))
report_store.store(
course_id,
u"{course_prefix}_{report_name}_{timestamp_str}.html".format(
course_prefix=course_filename_prefix_generator(course_id),
report_name=report_name,
timestamp_str=generated_at.strftime("%Y-%m-%d-%H%M")
),
output_buffer,
config={
'content_type': 'text/html',
'content_encoding': None,
}
)
tracker.emit(REPORT_REQUESTED_EVENT_NAME, {"report_type": report_name})
def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=too-many-statements
"""
For a given `course_id`, generate a grades CSV file for all students that
......@@ -1023,6 +1060,149 @@ def upload_may_enroll_csv(_xmodule_instance_args, _entry_id, course_id, task_inp
return task_progress.update_task_state(extra_meta=current_step)
def get_executive_report(course_id):
"""
Returns dict containing information about the course executive summary.
"""
single_purchase_total = PaidCourseRegistration.get_total_amount_of_purchased_item(course_id)
bulk_purchase_total = CourseRegCodeItem.get_total_amount_of_purchased_item(course_id)
paid_invoices_total = InvoiceTransaction.get_total_amount_of_paid_course_invoices(course_id)
gross_revenue = single_purchase_total + bulk_purchase_total + paid_invoices_total
all_invoices_total = Invoice.get_invoice_total_amount_for_course(course_id)
gross_pending_revenue = all_invoices_total - float(paid_invoices_total)
refunded_self_purchased_seats = PaidCourseRegistration.get_self_purchased_seat_count(
course_id, status='refunded'
)
refunded_bulk_purchased_seats = CourseRegCodeItem.get_bulk_purchased_seat_count(
course_id, status='refunded'
)
total_seats_refunded = refunded_self_purchased_seats + refunded_bulk_purchased_seats
self_purchased_refunds = PaidCourseRegistration.get_total_amount_of_purchased_item(
course_id,
status='refunded'
)
bulk_purchase_refunds = CourseRegCodeItem.get_total_amount_of_purchased_item(course_id, status='refunded')
total_amount_refunded = self_purchased_refunds + bulk_purchase_refunds
top_discounted_codes = CouponRedemption.get_top_discount_codes_used(course_id)
total_coupon_codes_purchases = CouponRedemption.get_total_coupon_code_purchases(course_id)
bulk_purchased_codes = CourseRegistrationCode.order_generated_registration_codes(course_id)
unused_registration_codes = 0
for registration_code in bulk_purchased_codes:
if not RegistrationCodeRedemption.is_registration_code_redeemed(registration_code.code):
unused_registration_codes += 1
self_purchased_seat_count = PaidCourseRegistration.get_self_purchased_seat_count(course_id)
bulk_purchased_seat_count = CourseRegCodeItem.get_bulk_purchased_seat_count(course_id)
total_invoiced_seats = CourseRegistrationCode.invoice_generated_registration_codes(course_id).count()
total_seats = self_purchased_seat_count + bulk_purchased_seat_count + total_invoiced_seats
self_purchases_percentage = 0.0
bulk_purchases_percentage = 0.0
invoice_purchases_percentage = 0.0
avg_price_paid = 0.0
if total_seats != 0:
self_purchases_percentage = (float(self_purchased_seat_count) / float(total_seats)) * 100
bulk_purchases_percentage = (float(bulk_purchased_seat_count) / float(total_seats)) * 100
invoice_purchases_percentage = (float(total_invoiced_seats) / float(total_seats)) * 100
avg_price_paid = gross_revenue / total_seats
course = get_course_by_id(course_id, depth=0)
currency = settings.PAID_COURSE_REGISTRATION_CURRENCY[1]
return {
'display_name': course.display_name,
'start_date': course.start.strftime("%Y-%m-%d") if course.start is not None else 'N/A',
'end_date': course.end.strftime("%Y-%m-%d") if course.end is not None else 'N/A',
'total_seats': total_seats,
'currency': currency,
'gross_revenue': float(gross_revenue),
'gross_pending_revenue': gross_pending_revenue,
'total_seats_refunded': total_seats_refunded,
'total_amount_refunded': float(total_amount_refunded),
'average_paid_price': float(avg_price_paid),
'discount_codes_data': top_discounted_codes,
'total_seats_using_discount_codes': total_coupon_codes_purchases,
'total_self_purchase_seats': self_purchased_seat_count,
'total_bulk_purchase_seats': bulk_purchased_seat_count,
'total_invoiced_seats': total_invoiced_seats,
'unused_bulk_purchase_code_count': unused_registration_codes,
'self_purchases_percentage': self_purchases_percentage,
'bulk_purchases_percentage': bulk_purchases_percentage,
'invoice_purchases_percentage': invoice_purchases_percentage,
}
def upload_exec_summary_report(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=too-many-statements
"""
For a given `course_id`, generate a html report containing information,
which provides a snapshot of how the course is doing.
"""
start_time = time()
report_generation_date = datetime.now(UTC)
status_interval = 100
enrolled_users = CourseEnrollment.objects.users_enrolled_in(course_id)
true_enrollment_count = 0
for user in enrolled_users:
if not user.is_staff and not CourseAccessRole.objects.filter(
user=user, course_id=course_id, role__in=FILTERED_OUT_ROLES
).exists():
true_enrollment_count += 1
task_progress = TaskProgress(action_name, true_enrollment_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)
current_step = {'step': 'Gathering executive summary report information'}
TASK_LOG.info(
u'%s, Task type: %s, Current step: %s, generating executive summary report',
task_info_string,
action_name,
current_step
)
if task_progress.attempted % status_interval == 0:
task_progress.update_task_state(extra_meta=current_step)
task_progress.attempted += 1
# get the course executive summary report information.
data_dict = get_executive_report(course_id)
data_dict.update(
{
'total_enrollments': true_enrollment_count,
'report_generation_date': report_generation_date.strftime("%Y-%m-%d"),
}
)
# By this point, we've got the data that we need to generate html report.
current_step = {'step': 'Uploading executive summary report HTML file'}
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_exec_summary_to_store(data_dict, 'executive_report', course_id, report_generation_date)
task_progress.succeeded += 1
# One last update before we close out...
TASK_LOG.info(u'%s, Task type: %s, Finalizing executive summary report 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
......
......@@ -18,6 +18,7 @@ from instructor_task.api import (
submit_cohort_students,
submit_detailed_enrollment_features_csv,
submit_calculate_may_enroll_csv,
submit_executive_summary_report
)
from instructor_task.api_helper import AlreadyRunningError
......@@ -214,6 +215,12 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
self.course.id)
self._test_resubmission(api_call)
def test_submit_executive_summary_report(self):
api_call = lambda: submit_executive_summary_report(
self.create_task_request(self.instructor), self.course.id
)
self._test_resubmission(api_call)
def test_submit_calculate_may_enroll(self):
api_call = lambda: submit_calculate_may_enroll_csv(
self.create_task_request(self.instructor),
......
......@@ -18,19 +18,16 @@ 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
upload_enrollment_report, upload_exec_summary_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, CourseEnrollmentAllowed, ManualEnrollmentAudit,
ALLOWEDTOENROLL_TO_ENROLLED
)
CourseRegistrationCodeInvoiceItem, InvoiceTransaction, Coupon
from student.tests.factories import UserFactory, CourseModeFactory
from student.models import CourseEnrollment, CourseEnrollmentAllowed, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED
from verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition
......@@ -715,6 +712,132 @@ class TestProblemReportCohortedContent(TestReportMixin, ContentGroupTestCase, In
@ddt.ddt
class TestExecutiveSummaryReport(TestReportMixin, InstructorTaskCourseTestCase):
"""
Tests that Executive Summary report generation works.
"""
def setUp(self):
super(TestExecutiveSummaryReport, self).setUp()
self.course = CourseFactory.create()
CourseModeFactory.create(course_id=self.course.id, min_price=50)
self.instructor = InstructorFactory(course_key=self.course.id)
self.student1 = UserFactory()
self.student2 = UserFactory()
self.student1_cart = Order.get_cart_for_user(self.student1)
self.student2_cart = Order.get_cart_for_user(self.student2)
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
)
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
)
self.invoice_item = CourseRegistrationCodeInvoiceItem.objects.create(
invoice=self.sale_invoice_1,
qty=10,
unit_price=1234.32,
course_id=self.course.id
)
for i in range(5):
coupon = Coupon(
code='coupon{0}'.format(i), description='test_description', course_id=self.course.id,
percentage_discount='{0}'.format(i), created_by=self.instructor, is_active=True,
)
coupon.save()
def test_successfully_generate_executive_summary_report(self):
"""
Test that successfully generates the executive summary report.
"""
task_input = {'features': []}
with patch('instructor_task.tasks_helper._get_current_task'):
result = upload_exec_summary_report(
None, None, self.course.id,
task_input, 'generating executive summary report'
)
ReportStore.from_config(config_name='FINANCIAL_REPORTS')
self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
def students_purchases(self):
"""
Students purchases the courses using enrollment
and coupon codes.
"""
self.client.login(username=self.student1.username, password='test')
paid_course_reg_item = PaidCourseRegistration.add_to_order(self.student1_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': 'coupon1'})
self.assertEqual(resp.status_code, 200)
self.student1_cart.purchase()
course_reg_codes = CourseRegistrationCode.objects.filter(order=self.student1_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)
self.client.login(username=self.student2.username, password='test')
PaidCourseRegistration.add_to_order(self.student2_cart, self.course.id)
# apply the coupon code to the item in the cart
resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': 'coupon1'})
self.assertEqual(resp.status_code, 200)
self.student2_cart.purchase()
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
def test_generate_executive_summary_report(self):
"""
test to generate executive summary report
and then test the report authenticity.
"""
self.students_purchases()
task_input = {'features': []}
with patch('instructor_task.tasks_helper._get_current_task'):
result = upload_exec_summary_report(
None, None, self.course.id,
task_input, 'generating executive summary report'
)
report_store = ReportStore.from_config(config_name='FINANCIAL_REPORTS')
expected_data = [
'Gross Revenue Collected', '$1481.82',
'Gross Revenue Pending', '$0.00',
'Average Price per Seat', '$296.36',
'Number of seats purchased using coupon codes', '<td>2</td>'
]
self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
self._verify_html_file_report(report_store, expected_data)
def _verify_html_file_report(self, report_store, expected_data):
"""
Verify grade report data.
"""
report_html_filename = report_store.links_for(self.course.id)[0][0]
with open(report_store.path_to(self.course.id, report_html_filename)) as html_file:
html_file_data = html_file.read()
for data in expected_data:
self.assertTrue(data in html_file_data)
@ddt.ddt
class TestStudentReport(TestReportMixin, InstructorTaskCourseTestCase):
"""
Tests that CSV student profile report generation works.
......
......@@ -22,7 +22,7 @@ from django.core.mail import send_mail
from django.contrib.auth.models import User
from django.utils.translation import ugettext as _, ugettext_lazy
from django.db import transaction
from django.db.models import Sum
from django.db.models import Sum, Count
from django.db.models.signals import post_save, post_delete
from django.core.urlresolvers import reverse
......@@ -834,6 +834,15 @@ class Invoice(TimeStampedModel):
)
is_valid = models.BooleanField(default=True)
@classmethod
def get_invoice_total_amount_for_course(cls, course_key):
"""
returns the invoice total amount generated by course.
"""
result = cls.objects.filter(course_id=course_key, is_valid=True).aggregate(total=Sum('total_amount')) # pylint: disable=no-member
return result.get('total', 0)
def generate_pdf_invoice(self, course, course_price, quantity, sale_price):
"""
Generates the pdf invoice for the given course
......@@ -995,6 +1004,17 @@ class InvoiceTransaction(TimeStampedModel):
except InvoiceTransaction.DoesNotExist:
return None
@classmethod
def get_total_amount_of_paid_course_invoices(cls, course_key):
"""
returns the total amount of the paid invoices.
"""
result = cls.objects.filter(amount__gt=0, invoice__course_id=course_key, status='completed').aggregate(
total=Sum('amount')
) # pylint: disable=no-member
return result.get('total', 0)
def snapshot(self):
"""Create a snapshot of the invoice transaction.
......@@ -1169,6 +1189,22 @@ class CourseRegistrationCode(models.Model):
invoice = models.ForeignKey(Invoice, null=True)
invoice_item = models.ForeignKey(CourseRegistrationCodeInvoiceItem, null=True)
@classmethod
def order_generated_registration_codes(cls, course_id):
"""
Returns the registration codes that were generated
via bulk purchase scenario.
"""
return cls.objects.filter(order__isnull=False, course_id=course_id)
@classmethod
def invoice_generated_registration_codes(cls, course_id):
"""
Returns the registration codes that were generated
via invoice.
"""
return cls.objects.filter(invoice__isnull=False, course_id=course_id)
class RegistrationCodeRedemption(models.Model):
"""
......@@ -1354,6 +1390,33 @@ class CouponRedemption(models.Model):
return is_redemption_applied
@classmethod
def get_top_discount_codes_used(cls, course_id):
"""
Returns the top discount codes used.
QuerySet = [
{
'coupon__percentage_discount': 22,
'coupon__code': '12',
'coupon__used_count': '2',
},
{
...
}
]
"""
return cls.objects.filter(order__status='purchased', coupon__course_id=course_id).values(
'coupon__code', 'coupon__percentage_discount'
).annotate(coupon__used_count=Count('coupon__code'))
@classmethod
def get_total_coupon_code_purchases(cls, course_id):
"""
returns total seats purchases using coupon codes
"""
return cls.objects.filter(order__status='purchased', coupon__course_id=course_id).aggregate(Count('coupon'))
class PaidCourseRegistration(OrderItem):
"""
......@@ -1364,6 +1427,13 @@ class PaidCourseRegistration(OrderItem):
course_enrollment = models.ForeignKey(CourseEnrollment, null=True)
@classmethod
def get_self_purchased_seat_count(cls, course_key, status='purchased'):
"""
returns the count of paid_course items filter by course_id and status.
"""
return cls.objects.filter(course_id=course_key, status=status).count()
@classmethod
def get_course_item_for_user_enrollment(cls, user, course_id, course_enrollment):
"""
Returns PaidCourseRegistration object if user has payed for
......@@ -1387,12 +1457,14 @@ class PaidCourseRegistration(OrderItem):
]
@classmethod
def get_total_amount_of_purchased_item(cls, course_key):
def get_total_amount_of_purchased_item(cls, course_key, status='purchased'):
"""
This will return the total amount of money that a purchased course generated
"""
total_cost = 0
result = cls.objects.filter(course_id=course_key, status='purchased').aggregate(total=Sum('unit_cost', field='qty * unit_cost')) # pylint: disable=no-member
result = cls.objects.filter(course_id=course_key, status=status).aggregate(
total=Sum('unit_cost', field='qty * unit_cost')
) # pylint: disable=no-member
if result['total'] is not None:
total_cost = result['total']
......@@ -1534,6 +1606,19 @@ class CourseRegCodeItem(OrderItem):
mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG)
@classmethod
def get_bulk_purchased_seat_count(cls, course_key, status='purchased'):
"""
returns the sum of bulk purchases seats.
"""
total = 0
result = cls.objects.filter(course_id=course_key, status=status).aggregate(total=Sum('qty'))
if result['total'] is not None:
total = result['total']
return total
@classmethod
def contained_in_order(cls, order, course_id):
"""
Is the course defined by course_id contained in the order?
......@@ -1545,12 +1630,14 @@ class CourseRegCodeItem(OrderItem):
]
@classmethod
def get_total_amount_of_purchased_item(cls, course_key):
def get_total_amount_of_purchased_item(cls, course_key, status='purchased'):
"""
This will return the total amount of money that a purchased course generated
"""
total_cost = 0
result = cls.objects.filter(course_id=course_key, status='purchased').aggregate(total=Sum('unit_cost', field='qty * unit_cost')) # pylint: disable=no-member
result = cls.objects.filter(course_id=course_key, status=status).aggregate(
total=Sum('unit_cost', field='qty * unit_cost')
) # pylint: disable=no-member
if result['total'] is not None:
total_cost = result['total']
......
......@@ -19,6 +19,7 @@ from django.conf import settings
from django.db import DatabaseError
from django.test import TestCase
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from django.contrib.auth.models import AnonymousUser
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
......@@ -28,8 +29,8 @@ from shoppingcart.models import (
InvalidCartItem, CourseRegistrationCode, PaidCourseRegistration, CourseRegCodeItem,
Donation, OrderItemSubclassPK,
Invoice, CourseRegistrationCodeInvoiceItem, InvoiceTransaction, InvoiceHistory,
RegistrationCodeRedemption
)
RegistrationCodeRedemption,
Coupon, CouponRedemption)
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
......@@ -431,11 +432,17 @@ class OrderItemTest(TestCase):
self.assertEquals(set([]), inst_set)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
class PaidCourseRegistrationTest(ModuleStoreTestCase):
"""
Paid Course Registration Tests.
"""
def setUp(self):
super(PaidCourseRegistrationTest, self).setUp()
self.user = UserFactory.create()
self.user.set_password('password')
self.user.save()
self.cost = 40
self.course = CourseFactory.create()
self.course_key = self.course.id
......@@ -444,8 +451,20 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
mode_display_name="honor cert",
min_price=self.cost)
self.course_mode.save()
self.percentage_discount = 20.0
self.cart = Order.get_cart_for_user(self.user)
def test_get_total_amount_of_purchased_items(self):
"""
Test to check the total amount of the
purchased items.
"""
PaidCourseRegistration.add_to_order(self.cart, self.course_key)
self.cart.purchase()
total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(course_key=self.course_key)
self.assertEqual(total_amount, 40.00)
def test_add_to_order(self):
reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_key)
......@@ -462,6 +481,109 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
self.assertEqual(self.cart.total_cost, self.cost)
def test_order_generated_registration_codes(self):
"""
Test to check for the order generated registration
codes.
"""
self.cart.order_type = 'business'
self.cart.save()
item = CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2)
self.cart.purchase()
registration_codes = CourseRegistrationCode.order_generated_registration_codes(self.course_key)
self.assertEqual(registration_codes.count(), item.qty)
def add_coupon(self, course_key, is_active, code):
"""
add dummy coupon into models
"""
Coupon.objects.create(
code=code,
description='testing code',
course_id=course_key,
percentage_discount=self.percentage_discount,
created_by=self.user,
is_active=is_active
)
def login_user(self, username):
"""
login the user to the platform.
"""
self.client.login(username=username, password="password")
def test_get_top_discount_codes_used(self):
"""
Test to check for the top coupon codes used.
"""
self.login_user(self.user.username)
self.add_coupon(self.course_key, True, 'Ad123asd')
self.add_coupon(self.course_key, True, '32213asd')
self.purchases_using_coupon_codes()
top_discounted_codes = CouponRedemption.get_top_discount_codes_used(self.course_key)
self.assertTrue(top_discounted_codes[0]['coupon__code'], 'Ad123asd')
self.assertTrue(top_discounted_codes[0]['coupon__used_count'], 1)
self.assertTrue(top_discounted_codes[1]['coupon__code'], '32213asd')
self.assertTrue(top_discounted_codes[1]['coupon__used_count'], 2)
def test_get_total_coupon_code_purchases(self):
"""
Test to assert the number of coupon code purchases.
"""
self.login_user(self.user.username)
self.add_coupon(self.course_key, True, 'Ad123asd')
self.add_coupon(self.course_key, True, '32213asd')
self.purchases_using_coupon_codes()
total_coupon_code_purchases = CouponRedemption.get_total_coupon_code_purchases(self.course_key)
self.assertTrue(total_coupon_code_purchases['coupon__count'], 3)
def test_get_self_purchased_seat_count(self):
"""
Test to assert the number of seats
purchased using individual purchases.
"""
PaidCourseRegistration.add_to_order(self.cart, self.course_key)
self.cart.purchase()
test_student = UserFactory.create()
test_student.set_password('password')
test_student.save()
self.cart = Order.get_cart_for_user(test_student)
PaidCourseRegistration.add_to_order(self.cart, self.course_key)
self.cart.purchase()
total_seats_count = PaidCourseRegistration.get_self_purchased_seat_count(course_key=self.course_key)
self.assertTrue(total_seats_count, 2)
def purchases_using_coupon_codes(self):
"""
helper method that uses coupon codes when purchasing courses.
"""
self.cart.order_type = 'business'
self.cart.save()
CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2)
resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': 'Ad123asd'})
self.assertEqual(resp.status_code, 200)
self.cart.purchase()
self.cart.clear()
self.cart = Order.get_cart_for_user(self.user)
self.cart.order_type = 'business'
self.cart.save()
CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2)
resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': 'Ad123asd'})
self.assertEqual(resp.status_code, 200)
self.cart.purchase()
self.cart.clear()
self.cart = Order.get_cart_for_user(self.user)
PaidCourseRegistration.add_to_order(self.cart, self.course_key)
resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': '32213asd'})
self.assertEqual(resp.status_code, 200)
self.cart.purchase()
def test_cart_type_business(self):
self.cart.order_type = 'business'
self.cart.save()
......@@ -469,7 +591,8 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
self.cart.purchase()
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_key))
# check that the registration codes are generated against the order
self.assertEqual(len(CourseRegistrationCode.objects.filter(order=self.cart)), item.qty)
registration_codes = CourseRegistrationCode.order_generated_registration_codes(self.course_key)
self.assertEqual(registration_codes.count(), item.qty)
def test_regcode_redemptions(self):
"""
......@@ -480,7 +603,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2)
self.cart.purchase()
reg_code = CourseRegistrationCode.objects.filter(order=self.cart)[0]
reg_code = CourseRegistrationCode.order_generated_registration_codes(self.course_key)[0]
enrollment = CourseEnrollment.enroll(self.user, self.course_key)
......@@ -505,7 +628,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
CourseRegCodeItem.add_to_order(self.cart, self.course_key, 2)
self.cart.purchase()
reg_codes = CourseRegistrationCode.objects.filter(order=self.cart)
reg_codes = CourseRegistrationCode.order_generated_registration_codes(self.course_key)
self.assertEqual(len(reg_codes), 2)
......@@ -984,10 +1107,34 @@ class InvoiceHistoryTest(TestCase):
super(InvoiceHistoryTest, self).setUp()
invoice_data = copy.copy(self.INVOICE_INFO)
invoice_data.update(self.CONTACT_INFO)
self.invoice = Invoice.objects.create(total_amount="123.45", **invoice_data)
self.course_key = CourseLocator('edX', 'DemoX', 'Demo_Course')
self.invoice = Invoice.objects.create(total_amount="123.45", course_id=self.course_key, **invoice_data)
self.user = UserFactory.create()
def test_get_invoice_total_amount(self):
"""
test to check the total amount
of the invoices for the course.
"""
total_amount = Invoice.get_invoice_total_amount_for_course(self.course_key)
self.assertEqual(total_amount, 123.45)
def test_get_total_amount_of_paid_invoices(self):
"""
Test to check the Invoice Transactions amount.
"""
InvoiceTransaction.objects.create(
invoice=self.invoice,
amount='123.45',
currency='usd',
comments='test comments',
status='completed',
created_by=self.user,
last_modified_by=self.user
)
total_amount_paid = InvoiceTransaction.get_total_amount_of_paid_course_invoices(self.course_key)
self.assertEqual(float(total_amount_paid), 123.45)
def test_invoice_contact_info_history(self):
self._assert_history_invoice_info(
is_valid=True,
......@@ -998,6 +1145,30 @@ class InvoiceHistoryTest(TestCase):
self._assert_history_items([])
self._assert_history_transactions([])
def test_invoice_generated_registration_codes(self):
"""
test filter out the registration codes
that were generated via Invoice.
"""
invoice_item = CourseRegistrationCodeInvoiceItem.objects.create(
invoice=self.invoice,
qty=5,
unit_price='123.45',
course_id=self.course_key
)
for i in range(5):
CourseRegistrationCode.objects.create(
code='testcode{counter}'.format(counter=i),
course_id=self.course_key,
created_by=self.user,
invoice=self.invoice,
invoice_item=invoice_item,
mode_slug='honor'
)
registration_codes = CourseRegistrationCode.invoice_generated_registration_codes(self.course_key)
self.assertEqual(registration_codes.count(), 5)
def test_invoice_history_items(self):
# Create an invoice item
CourseRegistrationCodeInvoiceItem.objects.create(
......
......@@ -367,7 +367,7 @@ class ReportDownloads
minWidth: 150
cssClass: "file-download-link"
formatter: (row, cell, value, columnDef, dataContext) ->
'<a href="' + dataContext['url'] + '">' + dataContext['name'] + '</a>'
'<a target="_blank" href="' + dataContext['url'] + '">' + dataContext['name'] + '</a>'
]
$table_placeholder = $ '<div/>', class: 'slickgrid'
......
......@@ -36,22 +36,39 @@ 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({
$('#enrollment-report-request-response').text(data['status']);
return $("#enrollment-report-request-response").css({
"display": "block"
});
},
error: function(std_ajax_err) {
request_response_error.text(gettext('Error generating grades. Please try again.'));
return $(".reports .msg-error").css({
$('#enrollment-report-request-response-error').text(gettext('There was a problem creating the report. Select "Create Executive Summary" to try again.'));
return $("#enrollment-report-request-response-error").css({
"display": "block"
});
}
});
});
$('input[name="exec-summary-report"]').click(function(){
var url = $(this).data('endpoint');
$.ajax({
dataType: "json",
url: url,
success: function (data) {
$("#exec-summary-report-request-response").text(data['status']);
return $("#exec-summary-report-request-response").css({
"display": "block"
});
},
error: function(std_ajax_err) {
$('#exec-summary-report-request-response-error').text(gettext('There was a problem creating the report. Select "Create Executive Summary" to try again.'));
return $("#exec-summary-report-request-response-error").css({
"display": "block"
});
}
......
......@@ -96,11 +96,20 @@ import pytz
<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'] }">
<p>${_("Create a .csv file that contains enrollment information for your course.")}</p>
<input type="button" class="add blue-button" name="user-enrollment-report" value="${_("Create 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>
<div class="request-response msg msg-confirm copy" id="enrollment-report-request-response"></div>
<div class="request-response-error msg msg-warning copy" id="enrollment-report-request-response-error"></div>
<br>
</span>
<span class="csv_tip">
<div>
<p>${_("Create an HTML file that contains an executive summary for this course.")}</p>
<input type="button" class="add blue-button" name="exec-summary-report" value="${_("Create Executive Summary")}" data-endpoint="${ section_data['exec_summary_report_url'] }">
</div>
<div class="request-response msg msg-confirm copy" id="exec-summary-report-request-response"></div>
<div class="request-response-error msg msg-warning copy" id="exec-summary-report-request-response-error"></div>
<br>
</span>
<div class="reports-download-container action-type-container">
......
<%! from django.utils.translation import ugettext as _ %>
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Executive Summary</title>
<style type="text/css">
body {
font-family: Arial, Helvetica, sans-serif;
font-size:14px;
line-height:22px;
margin: 10px;
}
.box-bg {
background:#f1f1f1;
padding:10px;
}
th {
padding:5px;
background:#ccc;
}
h2 {
margin-top:0
}
</style>
</head>
<body>
<table width="650" border="0" cellspacing="5" cellpadding="5">
<tr>
<td align="left" valign="top" class="box-bg"><h2>${_("Executive Summary for {display_name}".format(display_name=display_name))}</h2>
<table width="100%">
<tr>
<td width="300">${_("Course Start Date")}</td>
<td align="right"> ${start_date}</td>
</tr>
<tr>
<td width="300">${_("Course End Date")}</td>
<td align="right"> ${end_date}</td>
</tr>
<tr>
<td width="300">${_("Report Creation Date")}</td>
<td align="right"> ${report_generation_date}</td>
</tr>
<tr>
<td width="300">${_("Number of Seats")}</td>
<td align="right">${total_seats}</td>
</tr>
<tr>
<td width="300">${_("Number of Enrollments")}</td>
<td align="right">${total_enrollments}</td>
</tr>
<tr>
<td>${_("Gross Revenue Collected")}</td>
<td align="right">${currency}${"{0:0.2f}".format(gross_revenue)}</td>
</tr>
<tr>
<td>${_("Gross Revenue Pending")}</td>
<td align="right">${currency}${"{0:0.2f}".format(gross_pending_revenue)}</td>
</tr>
<tr>
<td>${_("Number of Enrollment Refunds")}</td>
<td align="right">${total_seats_refunded}</td>
</tr>
<tr>
<td>${_("Amount Refunded")}</td>
<td align="right">${currency}${"{0:0.2f}".format(total_amount_refunded)}</td>
</tr>
<tr>
<td>${_("Average Price per Seat")}</td>
<td align="right">${currency}${"{0:0.2f}".format(average_paid_price)}</td>
</tr>
</table></td>
</tr>
<tr>
<td align="left" valign="top" class="box-bg"><h3>${_("Frequently Used Coupon Codes")}</h3>
<table width="500">
<tr>
<td>${_("Number of seats purchased using coupon codes")}</td>
<td>${total_seats_using_discount_codes['coupon__count']}</td>
</tr>
</table>
<table width="100%">
<th>${_("Rank")}</th>
<th>${_("Coupon Code")}</th>
<th>${_("Percent Discount")}</th>
<th>${_("Times Used")}</th>
%for i, discount_code_data in enumerate(discount_codes_data):
<tr>
<td align="center">${i+1}</td>
<td align="center">${discount_code_data['coupon__code']}</td>
<td align="center">${discount_code_data['coupon__percentage_discount']}</td>
<td align="center">${discount_code_data['coupon__used_count']}</td>
</tr>
%endfor
</table></td>
</tr>
<tr>
<td align="left" valign="top" class="box-bg"><h3>${_("Bulk and Single Seat Purchases")}</h3>
<table width="100%">
<tr>
<td>${_("Number of seats purchased individually")}</td>
<td align="right">${total_self_purchase_seats}</td>
</tr>
<tr>
<td>${_("Number of seats purchased in bulk")}</td>
<td align="right">${total_bulk_purchase_seats}</td>
</tr>
<tr>
<td>${_("Number of seats purchased with invoices")}</td>
<td align="right">${total_invoiced_seats}</td>
</tr>
<tr>
<td>${_("Unused bulk purchase seats (revenue at risk)")}</td>
<td align="right">${unused_bulk_purchase_code_count}</td>
</tr>
<tr>
<td>${_("Percentage of seats purchased individually")}</td>
<td align="right">${"{0:0.2f}".format(self_purchases_percentage)}%</td>
</tr>
<tr>
<td>${_("Percentage of seats purchased in bulk")}</td>
<td align="right">${"{0:0.2f}".format(bulk_purchases_percentage)}%</td>
</tr>
<tr>
<td>${_("Percentage of seats purchased with invoices")}</td>
<td align="right">${"{0:0.2f}".format(invoice_purchases_percentage)}%</td>
</tr>
</table></td>
</tr>
</table>
</body>
</html>
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