Commit 2fafaec0 by Andy Armstrong Committed by Diana Huang

Implement grade report analytics

TNL-1988
parent 9269ec3b
...@@ -4,8 +4,6 @@ Utilities for django models. ...@@ -4,8 +4,6 @@ Utilities for django models.
from eventtracking import tracker from eventtracking import tracker
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.fields.related import RelatedField
from django_countries.fields import Country from django_countries.fields import Country
......
...@@ -702,12 +702,47 @@ class DataDownloadPage(PageObject): ...@@ -702,12 +702,47 @@ class DataDownloadPage(PageObject):
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='a[data-section=data_download].active-section').present return self.q(css='a[data-section=data_download].active-section').present
@property
def generate_student_profile_report_button(self):
"""
Returns the "Download profile information as a CSV" button.
"""
return self.q(css='input[name=list-profiles-csv]')
@property
def generate_grade_report_button(self):
"""
Returns the "Generate Grade Report" button.
"""
return self.q(css='input[name=calculate-grades-csv]')
@property
def generate_weighted_problem_grade_report_button(self):
"""
Returns the "Generate Weighted Problem Grade Report" button.
"""
return self.q(css='input[name=problem-grade-report]')
@property
def report_download_links(self):
"""
Returns the download links for the current page.
"""
return self.q(css="#report-downloads-table .file-download-link>a")
def wait_for_available_report(self):
"""
Waits for a downloadable report to be available.
"""
EmptyPromise(
lambda: len(self.report_download_links) >= 1, 'Waiting for downloadable report'
).fulfill()
def get_available_reports_for_download(self): def get_available_reports_for_download(self):
""" """
Returns a list of all the available reports for download. Returns a list of all the available reports for download.
""" """
reports = self.q(css="#report-downloads-table .file-download-link>a").map(lambda el: el.text) return self.report_download_links.map(lambda el: el.text)
return reports.results
class StudentAdminPage(PageObject): class StudentAdminPage(PageObject):
......
...@@ -14,7 +14,6 @@ from xmodule.partitions.partitions import Group ...@@ -14,7 +14,6 @@ from xmodule.partitions.partitions import Group
from ...fixtures.course import CourseFixture, XBlockFixtureDesc from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.instructor_dashboard import InstructorDashboardPage, DataDownloadPage from ...pages.lms.instructor_dashboard import InstructorDashboardPage, DataDownloadPage
from ...pages.studio.settings_advanced import AdvancedSettingsPage
from ...pages.studio.settings_group_configurations import GroupConfigurationsPage from ...pages.studio.settings_group_configurations import GroupConfigurationsPage
import uuid import uuid
...@@ -555,9 +554,7 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin ...@@ -555,9 +554,7 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin
# Verify the results can be downloaded. # Verify the results can be downloaded.
data_download = self.instructor_dashboard_page.select_data_download() data_download = self.instructor_dashboard_page.select_data_download()
EmptyPromise( data_download.wait_for_available_report()
lambda: 1 == len(data_download.get_available_reports_for_download()), 'Waiting for downloadable report'
).fulfill()
report = data_download.get_available_reports_for_download()[0] report = data_download.get_available_reports_for_download()[0]
base_file_name = "cohort_results_" base_file_name = "cohort_results_"
self.assertIn("{}_{}".format( self.assertIn("{}_{}".format(
......
...@@ -5,15 +5,36 @@ End-to-end tests for the LMS Instructor Dashboard. ...@@ -5,15 +5,36 @@ End-to-end tests for the LMS Instructor Dashboard.
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from ..helpers import UniqueCourseTest, get_modal_alert from ..helpers import UniqueCourseTest, get_modal_alert, EventsTestMixin
from ...pages.common.logout import LogoutPage from ...pages.common.logout import LogoutPage
from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.instructor_dashboard import InstructorDashboardPage from ...pages.lms.instructor_dashboard import InstructorDashboardPage
from ...fixtures.course import CourseFixture from ...fixtures.course import CourseFixture
class BaseInstructorDashboardTest(EventsTestMixin, UniqueCourseTest):
"""
Mixin class for testing the instructor dashboard.
"""
def log_in_as_instructor(self):
"""
Logs in as an instructor and returns the id.
"""
username = "test_instructor_{uuid}".format(uuid=self.unique_id[0:6])
auto_auth_page = AutoAuthPage(self.browser, username=username, course_id=self.course_id, staff=True)
return username, auto_auth_page.visit().get_user_id()
def visit_instructor_dashboard(self):
"""
Visits the instructor dashboard.
"""
instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
instructor_dashboard_page.visit()
return instructor_dashboard_page
@attr('shard_5') @attr('shard_5')
class AutoEnrollmentWithCSVTest(UniqueCourseTest): class AutoEnrollmentWithCSVTest(BaseInstructorDashboardTest):
""" """
End-to-end tests for Auto-Registration and enrollment functionality via CSV file. End-to-end tests for Auto-Registration and enrollment functionality via CSV file.
""" """
...@@ -21,13 +42,8 @@ class AutoEnrollmentWithCSVTest(UniqueCourseTest): ...@@ -21,13 +42,8 @@ class AutoEnrollmentWithCSVTest(UniqueCourseTest):
def setUp(self): def setUp(self):
super(AutoEnrollmentWithCSVTest, self).setUp() super(AutoEnrollmentWithCSVTest, self).setUp()
self.course_fixture = CourseFixture(**self.course_info).install() self.course_fixture = CourseFixture(**self.course_info).install()
self.log_in_as_instructor()
# login as an instructor instructor_dashboard_page = self.visit_instructor_dashboard()
AutoAuthPage(self.browser, course_id=self.course_id, staff=True).visit()
# go to the membership page on the instructor dashboard
instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
instructor_dashboard_page.visit()
self.auto_enroll_section = instructor_dashboard_page.select_membership().select_auto_enroll_section() self.auto_enroll_section = instructor_dashboard_page.select_membership().select_auto_enroll_section()
def test_browse_and_upload_buttons_are_visible(self): def test_browse_and_upload_buttons_are_visible(self):
...@@ -91,7 +107,7 @@ class AutoEnrollmentWithCSVTest(UniqueCourseTest): ...@@ -91,7 +107,7 @@ class AutoEnrollmentWithCSVTest(UniqueCourseTest):
@attr('shard_5') @attr('shard_5')
class EntranceExamGradeTest(UniqueCourseTest): class EntranceExamGradeTest(BaseInstructorDashboardTest):
""" """
Tests for Entrance exam specific student grading tasks. Tests for Entrance exam specific student grading tasks.
""" """
...@@ -112,13 +128,9 @@ class EntranceExamGradeTest(UniqueCourseTest): ...@@ -112,13 +128,9 @@ class EntranceExamGradeTest(UniqueCourseTest):
LogoutPage(self.browser).visit() LogoutPage(self.browser).visit()
# login as an instructor
AutoAuthPage(self.browser, course_id=self.course_id, staff=True).visit()
# go to the student admin page on the instructor dashboard # go to the student admin page on the instructor dashboard
instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id) self.log_in_as_instructor()
instructor_dashboard_page.visit() self.student_admin_section = self.visit_instructor_dashboard().select_student_admin()
self.student_admin_section = instructor_dashboard_page.select_student_admin()
def test_input_text_and_buttons_are_visible(self): def test_input_text_and_buttons_are_visible(self):
""" """
...@@ -291,3 +303,104 @@ class EntranceExamGradeTest(UniqueCourseTest): ...@@ -291,3 +303,104 @@ class EntranceExamGradeTest(UniqueCourseTest):
self.student_admin_section.set_student_email(self.student_identifier) self.student_admin_section.set_student_email(self.student_identifier)
self.student_admin_section.click_task_history_button() self.student_admin_section.click_task_history_button()
self.assertTrue(self.student_admin_section.is_background_task_history_table_visible()) self.assertTrue(self.student_admin_section.is_background_task_history_table_visible())
class DataDownloadsTest(BaseInstructorDashboardTest):
"""
Bok Choy tests for the "Data Downloads" tab.
"""
def setUp(self):
super(DataDownloadsTest, self).setUp()
self.course_fixture = CourseFixture(**self.course_info).install()
self.instructor_username, self.instructor_id = self.log_in_as_instructor()
instructor_dashboard_page = self.visit_instructor_dashboard()
self.data_download_section = instructor_dashboard_page.select_data_download()
def verify_report_requested_event(self, report_type):
"""
Verifies that the correct event is emitted when a report is requested.
"""
self.verify_events_of_type(
self.instructor_username,
u"edx.instructor.report.requested",
[{
u"report_type": report_type
}]
)
def verify_report_downloaded_event(self, report_url):
"""
Verifies that the correct event is emitted when a report is downloaded.
"""
self.verify_events_of_type(
self.instructor_username,
u"edx.instructor.report.downloaded",
[{
u"report_url": report_url
}]
)
def verify_report_download(self, report_name):
"""
Verifies that a report can be downloaded and an event fired.
"""
download_links = self.data_download_section.report_download_links
self.assertEquals(len(download_links), 1)
download_links[0].click()
expected_url = download_links.attrs('href')[0]
self.assertIn(report_name, expected_url)
self.verify_report_downloaded_event(expected_url)
def test_student_profiles_report_download(self):
"""
Scenario: Verify that an instructor can download a student profiles report
Given that I am an instructor
And I visit the instructor dashboard's "Data Downloads" tab
And I click on the "Download profile information as a CSV" button
Then a report should be generated
And a report requested event should be emitted
When I click on the report
Then a report downloaded event should be emitted
"""
report_name = u"student_profile_info"
self.data_download_section.generate_student_profile_report_button.click()
self.data_download_section.wait_for_available_report()
self.verify_report_requested_event(report_name)
self.verify_report_download(report_name)
def test_grade_report_download(self):
"""
Scenario: Verify that an instructor can download a grade report
Given that I am an instructor
And I visit the instructor dashboard's "Data Downloads" tab
And I click on the "Generate Grade Report" button
Then a report should be generated
And a report requested event should be emitted
When I click on the report
Then a report downloaded event should be emitted
"""
report_name = u"grade_report"
self.data_download_section.generate_grade_report_button.click()
self.data_download_section.wait_for_available_report()
self.verify_report_requested_event(report_name)
self.verify_report_download(report_name)
def test_weighted_problem_grade_report_download(self):
"""
Scenario: Verify that an instructor can download a weighted problem grade report
Given that I am an instructor
And I visit the instructor dashboard's "Data Downloads" tab
And I click on the "Generate Weighted Problem Grade Report" button
Then a report should be generated
And a report requested event should be emitted
When I click on the report
Then a report downloaded event should be emitted
"""
report_name = u"problem_grade_report"
self.data_download_section.generate_weighted_problem_grade_report_button.click()
self.data_download_section.wait_for_available_report()
self.verify_report_requested_event(report_name)
self.verify_report_download(report_name)
...@@ -23,8 +23,7 @@ def _retrieve_course(course_key): ...@@ -23,8 +23,7 @@ def _retrieve_course(course_key):
""" """
try: try:
course = courses.get_course(course_key) return courses.get_course(course_key)
return course
except ValueError: except ValueError:
raise CourseNotFoundError raise CourseNotFoundError
......
...@@ -156,7 +156,6 @@ def calculate_grades_csv(entry_id, xmodule_instance_args): ...@@ -156,7 +156,6 @@ def calculate_grades_csv(entry_id, xmodule_instance_args):
return run_main_task(entry_id, task_fn, action_name) return run_main_task(entry_id, task_fn, action_name)
# TODO: GRADES_DOWNLOAD_ROUTING_KEY is the high mem queue. Do we know we need it?
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable @task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable
def calculate_problem_grade_report(entry_id, xmodule_instance_args): def calculate_problem_grade_report(entry_id, xmodule_instance_args):
""" """
......
...@@ -55,6 +55,9 @@ UPDATE_STATUS_SUCCEEDED = 'succeeded' ...@@ -55,6 +55,9 @@ UPDATE_STATUS_SUCCEEDED = 'succeeded'
UPDATE_STATUS_FAILED = 'failed' UPDATE_STATUS_FAILED = 'failed'
UPDATE_STATUS_SKIPPED = 'skipped' UPDATE_STATUS_SKIPPED = 'skipped'
# The setting name used for events when "settings" (account settings, preferences, profile information) change.
REPORT_REQUESTED_EVENT_NAME = u'edx.instructor.report.requested'
class BaseInstructorTask(Task): class BaseInstructorTask(Task):
""" """
...@@ -553,6 +556,12 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp): ...@@ -553,6 +556,12 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp):
), ),
rows rows
) )
tracker.emit(
REPORT_REQUESTED_EVENT_NAME,
{
"report_type": csv_name,
}
)
def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=too-many-statements def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=too-many-statements
......
...@@ -109,10 +109,10 @@ class DataDownload ...@@ -109,10 +109,10 @@ class DataDownload
@$download_display_text.html data['grading_config_summary'] @$download_display_text.html data['grading_config_summary']
@$calculate_grades_csv_btn.click (e) => @$calculate_grades_csv_btn.click (e) =>
@onClickGradeDownload @$calculate_grades_csv_btn, "Error generating grades. Please try again." @onClickGradeDownload @$calculate_grades_csv_btn, gettext("Error generating grades. Please try again.")
@$problem_grade_report_csv_btn.click (e) => @$problem_grade_report_csv_btn.click (e) =>
@onClickGradeDownload @$problem_grade_report_csv_btn, "Error generating weighted problem report. Please try again." @onClickGradeDownload @$problem_grade_report_csv_btn, gettext("Error generating weighted problem report. Please try again.")
onClickGradeDownload: (button, errorMessage) -> onClickGradeDownload: (button, errorMessage) ->
# Clear any CSS styling from the request-response areas # Clear any CSS styling from the request-response areas
...@@ -124,7 +124,7 @@ class DataDownload ...@@ -124,7 +124,7 @@ class DataDownload
dataType: 'json' dataType: 'json'
url: url url: url
error: (std_ajax_err) => error: (std_ajax_err) =>
@$reports_request_response_error.text gettext(errorMessage) @$reports_request_response_error.text errorMessage
$(".msg-error").css({"display":"block"}) $(".msg-error").css({"display":"block"})
success: (data) => success: (data) =>
@$reports_request_response.text data['status'] @$reports_request_response.text data['status']
...@@ -201,6 +201,15 @@ class ReportDownloads ...@@ -201,6 +201,15 @@ class ReportDownloads
$table_placeholder = $ '<div/>', class: 'slickgrid' $table_placeholder = $ '<div/>', class: 'slickgrid'
@$report_downloads_table.append $table_placeholder @$report_downloads_table.append $table_placeholder
grid = new Slick.Grid($table_placeholder, report_downloads_data, columns, options) 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() grid.autosizeColumns()
......
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