Commit 7ef8949b by Dennis Jen

Engagement trend view and table displays engagement activities.

  * Refactored enrollment-trend-view to trend-view that displays multiple trends.
  * Using activities call on the api-client instead of deprecated recent_activity call.
  * Forum activities for trends put behind feature gate.
parent dca4d63a
...@@ -47,6 +47,28 @@ class FooterMixin(object): ...@@ -47,6 +47,28 @@ class FooterMixin(object):
self.assertEqual(element.text[0], u'Privacy Policy') self.assertEqual(element.text[0], u'Privacy Policy')
class CoursePageTestsMixin(object):
"""
Convenience methods for testing.
"""
def assertValidHref(self, selector):
element = self.page.q(css=selector)
self.assertTrue(element.present)
self.assertNotEqual(element.attrs('href')[0], '#')
def assertTableColumnHeadingsEqual(self, table_selector, headings):
rows = self.page.q(css=('%s thead th' % table_selector))
self.assertTrue(rows.present)
self.assertListEqual(rows.text, headings)
def assertElementHasContent(self, css):
element = self.page.q(css=css)
self.assertTrue(element.present)
html = element.html[0]
self.assertIsNotNone(html)
self.assertNotEqual(html, '')
def auto_auth(browser, server_url): def auto_auth(browser, server_url):
url = '{}/test/auto_auth/'.format(server_url) url = '{}/test/auto_auth/'.format(server_url)
return browser.get(url) return browser.get(url)
import datetime import datetime
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from bok_choy.promise import EmptyPromise
from analyticsclient import activity_type as at from analyticsclient import activity_type as at
from acceptance_tests import AnalyticsApiClientMixin, FooterMixin from acceptance_tests import AnalyticsApiClientMixin, CoursePageTestsMixin, FooterMixin
from acceptance_tests.pages import CourseEngagementContentPage from acceptance_tests.pages import CourseEngagementContentPage
_multiprocess_can_split_ = True _multiprocess_can_split_ = True
class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, WebAppTest): class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, CoursePageTestsMixin, WebAppTest):
""" """
Tests for the Engagement page. Tests for the Engagement page.
""" """
...@@ -27,17 +28,19 @@ class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, WebAppTest): ...@@ -27,17 +28,19 @@ class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, WebAppTest):
def test_page_exists(self): def test_page_exists(self):
self.page.visit() self.page.visit()
def test_student_activity(self): def test_engagement_summary(self):
self.page.visit() self.page.visit()
section_selector = "div[data-role=student-activity]" section_selector = "div[data-role=student-activity]"
section = self.page.q(css=section_selector) section = self.page.q(css=section_selector)
self.assertTrue(section.present) self.assertTrue(section.present)
# Verify the week displayed # Verify the week displayed
week = self.page.q(css=section_selector + ' span[data-role=activity-week]') week = self.page.q(css=section_selector + ' span[data-role=activity-week]')
self.assertTrue(week.present) self.assertTrue(week.present)
expected = self.course.recent_activity(at.ANY)['interval_end'] recent_activity = self.course.activity()[0]
expected = datetime.datetime.strptime(expected, "%Y-%m-%dT%H:%M:%SZ") expected = recent_activity['interval_end']
expected = datetime.datetime.strptime(expected, self.api_client.DATETIME_FORMAT)
expected = expected.strftime('%B %d, %Y') expected = expected.strftime('%B %d, %Y')
self.assertEqual(week.text[0], expected) self.assertEqual(week.text[0], expected)
...@@ -48,3 +51,46 @@ class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, WebAppTest): ...@@ -48,3 +51,46 @@ class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, WebAppTest):
element = self.page.q(css=selector) element = self.page.q(css=selector)
self.assertTrue(element.present) self.assertTrue(element.present)
self.assertEqual(int(element.text[0].replace(',', '')), self.course.recent_activity(activity_type)['count']) self.assertEqual(int(element.text[0].replace(',', '')), self.course.recent_activity(activity_type)['count'])
def test_engagement_graph(self):
self.page.visit()
# ensure that the trend data has finished loading
graph_selector = '#engagement-trend-view'
EmptyPromise(
lambda: 'Loading Trend...' not in self.page.q(css=graph_selector + ' p').text,
"Trend finished loading"
).fulfill()
self.assertElementHasContent(graph_selector)
def test_engagement_table(self):
self.page.visit()
date_time_format = self.api_client.DATETIME_FORMAT
recent_activity = self.course.activity()[0]
end_date = datetime.datetime.strptime(recent_activity['interval_end'], date_time_format) + datetime.timedelta(days=1)
start_date_string = (end_date - datetime.timedelta(days=60)).strftime(self.api_client.DATE_FORMAT)
end_date_string = end_date.strftime(self.api_client.DATE_FORMAT)
trend_activity = self.course.activity(start_date=start_date_string, end_date=end_date_string)
trend_activity = sorted(trend_activity, reverse=True, key=lambda item: item['interval_end'])
table_selector = 'div[data-role=engagement-table] table'
self.assertTableColumnHeadingsEqual(table_selector, ['Week Ending', 'Active Students', 'Tried a Problem', 'Watched a Video'])
rows = self.page.browser.find_elements_by_css_selector('%s tbody tr' % table_selector)
self.assertGreater(len(rows), 0)
for i, row in enumerate(rows):
columns = row.find_elements_by_css_selector('td')
weekly_activity = trend_activity[i]
expected_date = datetime.datetime.strptime(weekly_activity['interval_end'], date_time_format).strftime("%B %d, %Y").replace(' 0', ' ')
expected = [expected_date, weekly_activity[at.ANY], weekly_activity[at.ATTEMPTED_PROBLEM], weekly_activity[at.PLAYED_VIDEO]]
actual = [columns[0].text, int(columns[1].text), int(columns[2].text), int(columns[3].text)]
self.assertListEqual(actual, expected)
for j in range(1,4):
self.assertIn('text-right', columns[j].get_attribute('class'))
# Verify CSV button has an href attribute
selector = "a[data-role=engagement-trend-csv]"
self.assertValidHref(selector)
...@@ -4,13 +4,13 @@ from bok_choy.promise import EmptyPromise ...@@ -4,13 +4,13 @@ from bok_choy.promise import EmptyPromise
from analyticsclient import demographic from analyticsclient import demographic
from acceptance_tests import AnalyticsApiClientMixin, FooterMixin from acceptance_tests import AnalyticsApiClientMixin, CoursePageTestsMixin, FooterMixin
from acceptance_tests.pages import CourseEnrollmentActivityPage, CourseEnrollmentGeographyPage from acceptance_tests.pages import CourseEnrollmentActivityPage, CourseEnrollmentGeographyPage
_multiprocess_can_split_ = True _multiprocess_can_split_ = True
class CourseEnrollmentTests(AnalyticsApiClientMixin, FooterMixin): class CourseEnrollmentTests(AnalyticsApiClientMixin, FooterMixin, CoursePageTestsMixin):
""" """
Tests for the Enrollment page. Tests for the Enrollment page.
""" """
...@@ -22,23 +22,6 @@ class CourseEnrollmentTests(AnalyticsApiClientMixin, FooterMixin): ...@@ -22,23 +22,6 @@ class CourseEnrollmentTests(AnalyticsApiClientMixin, FooterMixin):
super(CourseEnrollmentTests, self).setUp() super(CourseEnrollmentTests, self).setUp()
self.api_date_format = self.api_client.DATE_FORMAT self.api_date_format = self.api_client.DATE_FORMAT
def assertValidHref(self, selector):
element = self.page.q(css=selector)
self.assertTrue(element.present)
self.assertNotEqual(element.attrs('href')[0], '#')
def assertTableColumnHeadingsEqual(self, table_selector, headings):
rows = self.page.q(css=('%s thead th' % table_selector))
self.assertTrue(rows.present)
self.assertListEqual(rows.text, headings)
def assertElementHasContent(self, css):
element = self.page.q(css=css)
self.assertTrue(element.present)
graph_html = element.html[0]
self.assertIsNotNone(graph_html)
self.assertNotEqual(graph_html, '')
def test_page_exists(self): def test_page_exists(self):
self.page.visit() self.page.visit()
......
...@@ -4,22 +4,27 @@ from django.conf import settings ...@@ -4,22 +4,27 @@ from django.conf import settings
from analyticsclient.client import Client from analyticsclient.client import Client
import analyticsclient.activity_type as AT import analyticsclient.activity_type as AT
from analyticsclient.exceptions import NotFoundError
from analyticsclient import demographic from analyticsclient import demographic
from waffle import switch_is_active
class BasePresenter(object): class BasePresenter(object):
""" """
This is the base class for the pages and sets up the analytics client This is the base class for the pages and sets up the analytics client
for the presenters to use to access the data API. for the presenters to use to access the data API.
""" """
default_date_interval = 60
def __init__(self, timeout=5): def __init__(self, course_id, timeout=5):
# API client # API client
self.client = Client(base_url=settings.DATA_API_URL, self.client = Client(base_url=settings.DATA_API_URL,
auth_token=settings.DATA_API_AUTH_TOKEN, auth_token=settings.DATA_API_AUTH_TOKEN,
timeout=timeout) timeout=timeout)
self.course_id = course_id
self.course = self.client.courses(self.course_id)
self.date_format = Client.DATE_FORMAT self.date_format = Client.DATE_FORMAT
self.date_time_format = Client.DATETIME_FORMAT
def parse_date(self, date_string): def parse_date(self, date_string):
""" """
...@@ -39,49 +44,74 @@ class BasePresenter(object): ...@@ -39,49 +44,74 @@ class BasePresenter(object):
""" """
return date.strftime(self.date_format) return date.strftime(self.date_format)
def parse_date_time_as_date(self, date_time_string):
"""
Parse a date time string to a Date object.
Arguments:
date_time_string (str): Date time string to parse
"""
return datetime.datetime.strptime(date_time_string, self.date_time_format).date()
def get_date_range(self, interval_end=None, num_days=None):
"""
Returns a start and end date that span the interval specified. If
interval_end is None, both start_date and end_date are returned as None.
"""
start_date = None
end_date = None
if interval_end is not None:
if num_days is None:
num_days = self.default_date_interval
start_date = interval_end - datetime.timedelta(days=num_days)
end_date = interval_end + datetime.timedelta(days=1)
return start_date, end_date
class CourseEngagementPresenter(BasePresenter): class CourseEngagementPresenter(BasePresenter):
""" """
Presenter for the engagement page. Presenter for the engagement page.
""" """
def get_summary(self, course_id): def get_trend_data(self, end_date=None, num_days=None):
""" """
Retrieve all summary numbers and time. Retrieve engagement activity trends for specified date range and return
results with zeros filled in for all activities.
Arguments:
course_id (str): ID of the course to retrieve summary information of.
""" """
course = self.client.courses(course_id) start_date, end_date = self.get_date_range(end_date, num_days)
api_trends = self.course.activity(start_date=start_date, end_date=end_date)
any_activity = course.recent_activity(AT.ANY) # feature gate posted_forum. If enabled, the forum will be rendered in the engagement page
trend_types = [AT.ANY, AT.PLAYED_VIDEO, AT.ATTEMPTED_PROBLEM]
if switch_is_active('show_engagement_forum_activity'):
trend_types.append(AT.POSTED_FORUM)
# store our activity data from the API # fill in gaps in activity with zero for display (api doesn't return
activities = [any_activity, ] # the field if no data exists for it, so we fill in the zeros here)
trends = []
for datum in api_trends:
trend_week = {'weekEnding': self.format_date(self.parse_date_time_as_date(datum['interval_end']))}
for trend_type in trend_types:
trend_week[trend_type] = datum[trend_type] if trend_type in datum else 0
trends.append(trend_week)
# let's assume that the interval starts are the same across return trends
# API calls and save it so that we can display this on
# the page
summary = {
'interval_end': any_activity['interval_end'],
}
# Create a list of data types and pass them into the recent_activity
# call to get all the summary data that I need.
activity_types = [AT.ATTEMPTED_PROBLEM, AT.PLAYED_VIDEO, AT.POSTED_FORUM]
for activity_type in activity_types:
try:
activity = course.recent_activity(activity_type)
except NotFoundError:
# We know the course exists, since we have gotten this far in the code, but
# there is no data for the specified activity type. Report it as null.
activity = {'activity_type': activity_type, 'count': None}
activities.append(activity) def get_summary(self):
"""
Retrieve all summary numbers and week ending time.
"""
# store our activity data from the API
api_activity = self.course.activity()[0]
summary = {'interval_end': self.parse_date_time_as_date(api_activity['interval_end'])}
# format the data for the page # fill in gaps in the summary if no data found so we can display a proper message
for activity in activities: activity_types = [AT.ANY, AT.ATTEMPTED_PROBLEM, AT.PLAYED_VIDEO, AT.POSTED_FORUM]
summary[activity['activity_type']] = activity['count'] for activity_type in activity_types:
if activity_type in api_activity:
summary[activity_type] = api_activity[activity_type]
else:
summary[activity_type] = None
return summary return summary
...@@ -89,18 +119,15 @@ class CourseEngagementPresenter(BasePresenter): ...@@ -89,18 +119,15 @@ class CourseEngagementPresenter(BasePresenter):
class CourseEnrollmentPresenter(BasePresenter): class CourseEnrollmentPresenter(BasePresenter):
""" Presenter for the course enrollment data. """ """ Presenter for the course enrollment data. """
def __init__(self, course_id, timeout=5): def get_trend_data(self, end_date=None, num_days=None):
super(CourseEnrollmentPresenter, self).__init__(timeout) start_date, end_date = self.get_date_range(end_date, num_days)
self.course_id = course_id return self.course.enrollment(start_date=start_date, end_date=end_date)
def get_trend_data(self, start_date=None, end_date=None):
return self.client.courses(self.course_id).enrollment(start_date=start_date, end_date=end_date)
def get_geography_data(self): def get_geography_data(self):
""" """
Returns a list of course geography data and the updated date (ex. 2014-1-31). Returns a list of course geography data and the updated date (ex. 2014-1-31).
""" """
api_response = self.client.courses(self.course_id).enrollment(demographic.LOCATION) api_response = self.course.enrollment(demographic.LOCATION)
data = [] data = []
update_date = None update_date = None
...@@ -122,9 +149,6 @@ class CourseEnrollmentPresenter(BasePresenter): ...@@ -122,9 +149,6 @@ class CourseEnrollmentPresenter(BasePresenter):
def get_summary(self): def get_summary(self):
""" """
Returns the summary information for enrollments. Returns the summary information for enrollments.
Arguments:
course_id (str): ID of the course to retrieve summary information of.
""" """
# Establish default return values # Establish default return values
...@@ -139,11 +163,9 @@ class CourseEnrollmentPresenter(BasePresenter): ...@@ -139,11 +163,9 @@ class CourseEnrollmentPresenter(BasePresenter):
if recent_enrollment: if recent_enrollment:
# Get data for a month prior to most-recent data # Get data for a month prior to most-recent data
days_in_week = 7
last_enrollment_date = self.parse_date(recent_enrollment[0]['date']) last_enrollment_date = self.parse_date(recent_enrollment[0]['date'])
month_before = last_enrollment_date - datetime.timedelta(days=31) last_week_enrollment = self.get_trend_data(end_date=last_enrollment_date, num_days=days_in_week)
start_date = month_before
end_date = last_enrollment_date + datetime.timedelta(days=1)
last_month_enrollment = self.get_trend_data(start_date=start_date, end_date=end_date)
# Add the first values to the returned data dictionary using the most-recent enrollment data # Add the first values to the returned data dictionary using the most-recent enrollment data
current_enrollment = recent_enrollment[0]['count'] current_enrollment = recent_enrollment[0]['count']
...@@ -152,16 +174,14 @@ class CourseEnrollmentPresenter(BasePresenter): ...@@ -152,16 +174,14 @@ class CourseEnrollmentPresenter(BasePresenter):
'current_enrollment': current_enrollment 'current_enrollment': current_enrollment
} }
# Keep track of the number of days of enrollment data, so we don't have to calculate it multiple times in # we could get fewer days of data than desired
# the loop below. num_days_of_data = len(last_week_enrollment)
num_days_of_data = len(last_month_enrollment)
# Get difference in enrollment for last week # Get difference in enrollment for last week
interval = 7
count = None count = None
if num_days_of_data > interval: if num_days_of_data > days_in_week:
index = -interval - 1 index = -days_in_week - 1
count = current_enrollment - last_month_enrollment[index]['count'] count = current_enrollment - last_week_enrollment[index]['count']
data['enrollment_change_last_%s_days' % interval] = count data['enrollment_change_last_%s_days' % days_in_week] = count
return data return data
{% extends "courses/base-course.html" %} {% extends "courses/base-course.html" %}
{% load i18n %}
{% load humanize %} {% load humanize %}
{% load staticfiles %} {% load staticfiles %}
{% load waffle_tags %} {% load waffle_tags %}
...@@ -17,21 +18,34 @@ Individual course-centric engagement content view. ...@@ -17,21 +18,34 @@ Individual course-centric engagement content view.
{% block content %} {% block content %}
<section class="view-section"> <section class="view-section">
<h4 class="section-title">
<span class="section-title-value">Student Activity</span>
<span class="section-title-note small">What are my students interacting with?</span>
</h4>
<hr class="has-emphasis"/>
<div class="section-content section-data-summary" data-role="student-activity"> <div class="section-content section-data-summary" data-role="student-activity">
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
<span class="section-title-value small">Activity for the week ending <span class="section-title-value small">{% trans "Activity for the week ending" %}
<span data-role="activity-week">{{ summary.week_of_activity }}</span> <span data-role="activity-week">{{ summary.week_of_activity|date}}</span>
</span> </span>
</div> </div>
</div> </div>
<div class="section-content section-data-graph">
<div class="section-content section-data-viz">
<div id="engagement-trend-view">
<div class="line-chart-container"><div class="line-chart ">
{% comment %}Translators: "Trend" is a graph of weekly student activity. Please translate accordingly.{% endcomment %}
{% trans "Loading Trend..." as loading_msg %}
{% include "loading.html" with message=loading_msg %}
</div></div>
</div>
</div>
</div>
<h4 class="section-title">
<span class="section-title-value">{% trans "Student Activity" %}</span>
<span class="section-title-note small">{% trans "What are my students interacting with?" %}</span>
</h4>
<hr/>
<div class="row"> <div class="row">
<div class="col-xs-12 col-sm-3" data-activity-type="any"> <div class="col-xs-12 col-sm-3" data-activity-type="any">
{% include "summary_point.html" with count=summary.any|intcomma label="Active Students" subheading="last week" tooltip=tooltips.all_activity_summary only %} {% include "summary_point.html" with count=summary.any|intcomma label="Active Students" subheading="last week" tooltip=tooltips.all_activity_summary only %}
...@@ -53,6 +67,22 @@ Individual course-centric engagement content view. ...@@ -53,6 +67,22 @@ Individual course-centric engagement content view.
</div> </div>
</div> </div>
<h4 class="section-title">
<span class="section-title-value">{% blocktrans %}Engagement Breakdown{% endblocktrans %}</span>
<div class="section-actions section-title-button">
<a href="{% url 'courses:csv_engagement_activity_trend' course_id=course_id %}" class="btn btn-default"
data-role="engagement-trend-csv" data-track-type="click"
data-track-event="edx.bi.csv.downloaded" data-track-category="trend">
<i class="ico fa fa-download"></i> {% trans "Download CSV" %}
</a>
</div>
</h4>
<div class="section-content section-data-table" data-role="engagement-table">
{% trans "Loading Table..." as loading_msg %}
{% include "loading.html" with message=loading_msg %}
</div>
{% switch show_engagement_demo_interface %} {% switch show_engagement_demo_interface %}
<hr/> <hr/>
<div data-role="demo-interface"> <div data-role="demo-interface">
......
import mock import mock
import datetime import datetime
from waffle import Switch
from django.test import TestCase from django.test import TestCase
import analyticsclient.activity_type as AT import analyticsclient.activity_type as AT
from analyticsclient.exceptions import NotFoundError
from courses.presenters import CourseEngagementPresenter, CourseEnrollmentPresenter, BasePresenter from courses.presenters import CourseEngagementPresenter, CourseEnrollmentPresenter, BasePresenter
from courses.tests.utils import get_mock_enrollment_data, get_mock_enrollment_summary, \ from courses.tests.utils import get_mock_enrollment_data, get_mock_enrollment_summary, \
get_mock_api_enrollment_geography_data, get_mock_presenter_enrollment_geography_data get_mock_api_enrollment_geography_data, get_mock_presenter_enrollment_geography_data
def mock_activity_data(activity_type): def mock_activity_data():
if activity_type == AT.POSTED_FORUM: # AT.POSTED_FORUM deliberately left off to test edge case where activity is empty
raise NotFoundError activity_types = [AT.ANY, AT.ATTEMPTED_PROBLEM, AT.PLAYED_VIDEO]
activity_types = [AT.ANY, AT.ATTEMPTED_PROBLEM, AT.PLAYED_VIDEO, AT.POSTED_FORUM] summaries = {
'interval_end': '2014-01-01T000000',
summaries = {} }
count = 0 count = 0
for activity in activity_types: for activity in activity_types:
summaries[activity] = { summaries[activity] = 500 * count
'interval_end': 'this is a time ' + str(count),
'activity_type': activity,
'count': 500 * count
}
count += 1 count += 1
return summaries[activity_type] return [summaries]
def mock_activity_trend_data(start_date=None, end_date=None):
# pylint: disable=unused-argument
return [
{
'interval_end': '2014-01-01T000000',
AT.ANY: 100,
AT.PLAYED_VIDEO: 200,
AT.POSTED_FORUM: 300
}, {
'interval_end': '2014-01-07T000000',
AT.ANY: 0,
AT.ATTEMPTED_PROBLEM: 200,
}
]
class CourseEngagementPresenterTests(TestCase): class CourseEngagementPresenterTests(TestCase):
def setUp(self): def setUp(self):
super(CourseEngagementPresenterTests, self).setUp() super(CourseEngagementPresenterTests, self).setUp()
self.presenter = CourseEngagementPresenter() self.presenter = CourseEngagementPresenter('this/course/id')
@mock.patch('analyticsclient.course.Course.recent_activity', mock.Mock(side_effect=mock_activity_data)) @mock.patch('analyticsclient.course.Course.activity', mock.Mock(side_effect=mock_activity_data))
def test_get_summary(self): def test_get_summary(self):
summary = self.presenter.get_summary('this/course/id') summary = self.presenter.get_summary()
# make sure that we get the time from "ANY" # make sure that we get the time from "ANY"
self.assertEqual(summary['interval_end'], 'this is a time 0') self.assertEqual(summary['interval_end'], datetime.date(year=2014, month=1, day=1))
# make sure that activity counts all match up # make sure that activity counts all match up
self.assertEqual(summary[AT.ANY], 0) self.assertEqual(summary[AT.ANY], 0)
...@@ -49,18 +61,42 @@ class CourseEngagementPresenterTests(TestCase): ...@@ -49,18 +61,42 @@ class CourseEngagementPresenterTests(TestCase):
# If an API query for a non-default activity type returns a 404, the presenter should return none for # If an API query for a non-default activity type returns a 404, the presenter should return none for
# that particular activity type. # that particular activity type.
self.assertIsNone(summary[AT.POSTED_FORUM], None) self.assertIsNone(summary[AT.POSTED_FORUM])
@mock.patch('analyticsclient.course.Course.activity', mock.Mock(side_effect=mock_activity_trend_data))
def test_get_trend_data(self):
Switch.objects.create(name='show_engagement_forum_activity', active=True)
trends = self.presenter.get_trend_data()
# check to see if we get the right values back and that None is filled to 0
expected = {
'weekEnding': '2014-01-01',
AT.ANY: 100,
AT.PLAYED_VIDEO: 200,
AT.POSTED_FORUM: 300,
AT.ATTEMPTED_PROBLEM: 0
}
self.assertDictEqual(trends[0], expected)
expected = {
'weekEnding': '2014-01-07',
AT.ANY: 0,
AT.PLAYED_VIDEO: 0,
AT.POSTED_FORUM: 0,
AT.ATTEMPTED_PROBLEM: 200
}
self.assertDictEqual(trends[1], expected)
class BasePresenterTests(TestCase): class BasePresenterTests(TestCase):
def setUp(self): def setUp(self):
self.presenter = BasePresenter() self.presenter = BasePresenter('edX/DemoX/Demo_Course')
def test_init(self): def test_init(self):
presenter = BasePresenter() presenter = BasePresenter('edX/DemoX/Demo_Course')
self.assertEqual(presenter.client.timeout, 5) self.assertEqual(presenter.client.timeout, 5)
presenter = BasePresenter(timeout=15) presenter = BasePresenter('edX/DemoX/Demo_Course', timeout=15)
self.assertEqual(presenter.client.timeout, 15) self.assertEqual(presenter.client.timeout, 15)
def test_parse_date(self): def test_parse_date(self):
...@@ -69,6 +105,10 @@ class BasePresenterTests(TestCase): ...@@ -69,6 +105,10 @@ class BasePresenterTests(TestCase):
def test_format_date(self): def test_format_date(self):
self.assertEqual(self.presenter.format_date(datetime.date(year=2014, month=1, day=1)), '2014-01-01') self.assertEqual(self.presenter.format_date(datetime.date(year=2014, month=1, day=1)), '2014-01-01')
def test_parse_date_time_as_date(self):
self.assertEqual(self.presenter.parse_date_time_as_date('2014-01-01T000000'),
datetime.date(year=2014, month=1, day=1))
class CourseEnrollmentPresenterTests(TestCase): class CourseEnrollmentPresenterTests(TestCase):
def setUp(self): def setUp(self):
...@@ -100,7 +140,7 @@ class CourseEnrollmentPresenterTests(TestCase): ...@@ -100,7 +140,7 @@ class CourseEnrollmentPresenterTests(TestCase):
def test_get_trend_data(self, mock_enrollment): def test_get_trend_data(self, mock_enrollment):
expected = get_mock_enrollment_data(self.course_id) expected = get_mock_enrollment_data(self.course_id)
mock_enrollment.return_value = expected mock_enrollment.return_value = expected
actual = self.presenter.get_trend_data(self.course_id) actual = self.presenter.get_trend_data()
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
@mock.patch('analyticsclient.course.Course.enrollment') @mock.patch('analyticsclient.course.Course.enrollment')
......
...@@ -69,9 +69,47 @@ def set_empty_permissions(user): ...@@ -69,9 +69,47 @@ def set_empty_permissions(user):
def mock_engagement_summary_data(): def mock_engagement_summary_data():
return { return {
'interval_end': '2013-01-01T12:12:12Z', 'interval_end': datetime.date(year=2013, month=1, day=1),
AT.ANY: 100, AT.ANY: 100,
AT.ATTEMPTED_PROBLEM: 301, AT.ATTEMPTED_PROBLEM: 301,
AT.PLAYED_VIDEO: 1000, AT.PLAYED_VIDEO: 1000,
AT.POSTED_FORUM: 0, AT.POSTED_FORUM: 0,
} }
def mock_engagement_activity_trend_data():
return [
{
'weekEnding': '2013-01-08',
AT.ANY: 100,
AT.ATTEMPTED_PROBLEM: 301,
AT.PLAYED_VIDEO: 1000,
AT.POSTED_FORUM: 0,
},
{
'weekEnding': '2013-01-01',
AT.ANY: 1000,
AT.ATTEMPTED_PROBLEM: 0,
AT.PLAYED_VIDEO: 10000,
AT.POSTED_FORUM: 45,
}
]
def mock_api_engagement_activity_trend_data():
return [
{
'interval_end': '2014-09-01T000000',
AT.ANY: 1000,
AT.ATTEMPTED_PROBLEM: 0,
AT.PLAYED_VIDEO: 10000,
AT.POSTED_FORUM: 45,
},
{
'interval_end': '2014-09-08T000000',
AT.ANY: 100,
AT.ATTEMPTED_PROBLEM: 301,
AT.PLAYED_VIDEO: 1000,
AT.POSTED_FORUM: 0,
},
]
...@@ -13,6 +13,7 @@ COURSE_URLS = [ ...@@ -13,6 +13,7 @@ COURSE_URLS = [
('overview', views.OverviewView.as_view()), ('overview', views.OverviewView.as_view()),
('csv/enrollment', views.CourseEnrollmentCSV.as_view()), ('csv/enrollment', views.CourseEnrollmentCSV.as_view()),
('csv/enrollment_by_country', views.CourseEnrollmentByCountryCSV.as_view()), ('csv/enrollment_by_country', views.CourseEnrollmentByCountryCSV.as_view()),
('csv/engagement_activity_trend', views.CourseEngagementActivityTrendCSV.as_view()),
] ]
COURSE_ID_REGEX = r'^(?P<course_id>(\w+/){2}\w+)' COURSE_ID_REGEX = r'^(?P<course_id>(\w+/){2}\w+)'
......
...@@ -17,7 +17,7 @@ from analyticsclient.exceptions import NotFoundError ...@@ -17,7 +17,7 @@ from analyticsclient.exceptions import NotFoundError
from courses import permissions from courses import permissions
from courses.presenters import CourseEngagementPresenter, CourseEnrollmentPresenter from courses.presenters import CourseEngagementPresenter, CourseEnrollmentPresenter
from courses.utils import get_formatted_date, get_formatted_date_time, is_feature_enabled from courses.utils import get_formatted_date, is_feature_enabled
class CourseContextMixin(object): class CourseContextMixin(object):
...@@ -275,14 +275,8 @@ class EnrollmentActivityView(EnrollmentTemplateView): ...@@ -275,14 +275,8 @@ class EnrollmentActivityView(EnrollmentTemplateView):
presenter = CourseEnrollmentPresenter(self.course_id) presenter = CourseEnrollmentPresenter(self.course_id)
try: summary = presenter.get_summary()
summary = presenter.get_summary() data = presenter.get_trend_data(end_date=summary['date'])
end_date = summary['date']
start_date = end_date - datetime.timedelta(days=60)
end_date = end_date + datetime.timedelta(days=1)
data = presenter.get_trend_data(start_date=start_date, end_date=end_date)
except NotFoundError:
raise Http404
# add the enrollment data for the page # add the enrollment data for the page
context['js_data']['course']['enrollmentTrends'] = data context['js_data']['course']['enrollmentTrends'] = data
...@@ -306,11 +300,7 @@ class EnrollmentGeographyView(EnrollmentTemplateView): ...@@ -306,11 +300,7 @@ class EnrollmentGeographyView(EnrollmentTemplateView):
context = super(EnrollmentGeographyView, self).get_context_data(**kwargs) context = super(EnrollmentGeographyView, self).get_context_data(**kwargs)
presenter = CourseEnrollmentPresenter(self.course_id) presenter = CourseEnrollmentPresenter(self.course_id)
data, last_update = presenter.get_geography_data()
try:
data, last_update = presenter.get_geography_data()
except NotFoundError:
raise Http404
context['js_data']['course']['enrollmentByCountry'] = data context['js_data']['course']['enrollmentByCountry'] = data
context['js_data']['course']['enrollmentByCountryUpdateDate'] = get_formatted_date(last_update) context['js_data']['course']['enrollmentByCountryUpdateDate'] = get_formatted_date(last_update)
...@@ -339,10 +329,13 @@ class EngagementContentView(EngagementTemplateView): ...@@ -339,10 +329,13 @@ class EngagementContentView(EngagementTemplateView):
'played_video_summary': _('Students who played one or more videos'), 'played_video_summary': _('Students who played one or more videos'),
} }
presenter = CourseEngagementPresenter() presenter = CourseEngagementPresenter(self.course_id)
summary = presenter.get_summary(self.course_id) summary = presenter.get_summary()
end_date = summary['interval_end']
trends = presenter.get_trend_data(end_date=end_date)
summary['week_of_activity'] = get_formatted_date_time(summary['interval_end']) context['js_data']['course']['engagementTrends'] = trends
summary['week_of_activity'] = end_date
context.update({ context.update({
'tooltips': tooltips, 'tooltips': tooltips,
...@@ -395,6 +388,18 @@ class CourseEnrollmentCSV(CSVResponseMixin, CourseView): ...@@ -395,6 +388,18 @@ class CourseEnrollmentCSV(CSVResponseMixin, CourseView):
return context return context
class CourseEngagementActivityTrendCSV(CSVResponseMixin, CourseView):
def get_context_data(self, **kwargs):
context = super(CourseEngagementActivityTrendCSV, self).get_context_data(**kwargs)
end_date = datetime.date.today().strftime(Client.DATE_FORMAT)
context.update({
'data': self.course.activity(data_format=data_format.CSV, end_date=end_date),
'filename': '{0}_engagement_activity_trend.csv'.format(self.course_id)
})
return context
class CourseHome(LoginRequiredMixin, RedirectView): class CourseHome(LoginRequiredMixin, RedirectView):
permanent = False permanent = False
......
...@@ -6,5 +6,75 @@ ...@@ -6,5 +6,75 @@
require(['vendor/domReady!', 'load/init-page'], function(doc, page){ require(['vendor/domReady!', 'load/init-page'], function(doc, page){
'use strict'; 'use strict';
// page specific code goes here... require(['views/data-table-view', 'views/trends-view'], function (DataTableView, TrendsView) {
// shared settings between the chart and table
// colors are chosen to be color-blind accessible
var settings = [
{
key: 'weekEnding',
title: gettext('Week Ending'),
type: 'date'
},{
key: 'any',
title: gettext('Active Students'),
color: '#8DA0CB',
className: 'text-right'
},{
key: 'posted_forum',
title: gettext('Posted in Forum'),
color: '#E78AC3',
className: 'text-right'
},{
key: 'attempted_problem',
title: gettext('Tried a Problem'),
color: '#FC8D62',
className: 'text-right'
},{
key: 'played_video',
title: gettext('Watched a Video'),
color: '#66C2A5',
className: 'text-right'
}],
trendSettings;
// remove settings for data that doesn't exist (ex. forums)
settings = _(settings).filter(function (setting) {
return page.models.courseModel.hasTrend('engagementTrends', setting.key);
});
// trend settings don't need weekEnding
trendSettings = _(settings).filter(function (setting) {
return setting.key !== 'weekEnding';
});
// weekly engagement activities graph
new TrendsView({
el: '#engagement-trend-view',
model: page.models.courseModel,
modelAttribute: 'engagementTrends',
trends: trendSettings,
title: gettext('Weekly Student Engagement'),
x: {
// displayed on the axis
title: 'Date',
// key in the data
key: 'weekEnding'
},
y: {
title: 'Students',
key: 'count'
}
// TODO: add a tooltip for this graph (AN-3362)
// ,tooltip: gettext('TBD')
});
// weekly engagement activities table
new DataTableView({
el: '[data-role=engagement-table]',
model: page.models.courseModel,
modelAttribute: 'engagementTrends',
columns: settings,
sorting: ['-weekEnding']
});
});
}); });
...@@ -8,14 +8,27 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page){ ...@@ -8,14 +8,27 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page){
// this is your page specific code // this is your page specific code
require(['views/data-table-view', require(['views/data-table-view',
'views/enrollment-trend-view'], 'views/trends-view'],
function (DataTableView, EnrollmentTrendView) { function (DataTableView, TrendsView) {
// Daily enrollment graph // Daily enrollment graph
new EnrollmentTrendView({ new TrendsView({
el: '#enrollment-trend-view', el: '#enrollment-trend-view',
model: page.models.courseModel, model: page.models.courseModel,
modelAttribute: 'enrollmentTrends' modelAttribute: 'enrollmentTrends',
trends: [{label: 'Students'}],
title: gettext('Daily Student Enrollment'),
x: {
// displayed on the axis
title: 'Date',
// key in the data
key: 'date'
},
y: {
title: 'Students',
key: 'count'
},
tooltip: gettext('This graph displays total enrollment for the course calculated at the end of each day. Total enrollment includes new enrollments as well as un-enrollments.')
}); });
// Daily enrollment table // Daily enrollment table
......
...@@ -12,6 +12,25 @@ define(['backbone', 'jquery'], function(Backbone, $) { ...@@ -12,6 +12,25 @@ define(['backbone', 'jquery'], function(Backbone, $) {
isEmpty: function() { isEmpty: function() {
var self = this; var self = this;
return !self.has('courseId'); return !self.has('courseId');
},
/**
* Returns whether the trend data is available with the assumption that
* data isn't sparse (there are values for fields across all rows).
*
* @param attribute Attribute name to reference the trend dataset.
* @param dataType Field in question.
*/
hasTrend: function(attribute, dataType) {
var self = this,
trendData = self.get(attribute),
hasTrend = false;
if (_(trendData).size()) {
hasTrend = _(trendData[0]).has(dataType);
}
return hasTrend;
} }
}); });
......
...@@ -67,6 +67,7 @@ if(isBrowser) { ...@@ -67,6 +67,7 @@ if(isBrowser) {
config.baseUrl + 'js/spec/specs/attribute-view-spec.js', config.baseUrl + 'js/spec/specs/attribute-view-spec.js',
config.baseUrl + 'js/spec/specs/course-model-spec.js', config.baseUrl + 'js/spec/specs/course-model-spec.js',
config.baseUrl + 'js/spec/specs/tracking-model-spec.js', config.baseUrl + 'js/spec/specs/tracking-model-spec.js',
config.baseUrl + 'js/spec/specs/trends-view-spec.js',
config.baseUrl + 'js/spec/specs/world-map-view-spec.js', config.baseUrl + 'js/spec/specs/world-map-view-spec.js',
config.baseUrl + 'js/spec/specs/tracking-view-spec.js', config.baseUrl + 'js/spec/specs/tracking-view-spec.js',
config.baseUrl + 'js/spec/specs/utils-spec.js' config.baseUrl + 'js/spec/specs/utils-spec.js'
......
...@@ -12,5 +12,18 @@ define(['models/course-model'], function(CourseModel) { ...@@ -12,5 +12,18 @@ define(['models/course-model'], function(CourseModel) {
expect(model.isEmpty()).toBe(false); expect(model.isEmpty()).toBe(false);
expect(model.get('courseId')).toBe('test'); expect(model.get('courseId')).toBe('test');
}); });
it('should determine if trend data is available ', function () {
var model = new CourseModel();
// the trend dataset is entirely unavailable
expect(model.hasTrend('noDataProvided', 'test')).toBe(false);
// the dataset is now available
model.set('trendData', [{data: 10}, {data: 20}]);
expect(model.hasTrend('trendData', 'data')).toBe(true);
expect(model.hasTrend('trendData', 'notFound')).toBe(false);
});
}); });
}); });
define(['models/course-model', 'views/trends-view'], function(CourseModel, TrendsView) {
'use strict';
describe('Trends view', function () {
it('should assemble data for nvd3', function () {
var model = new CourseModel(),
view = new TrendsView({
model: model,
el: document.createElement('div'),
modelAttribute: 'trends',
trends: [
{
key: 'trendA',
title: 'A Label',
color: '#8DA0CB'
},{
key: 'trendB',
title: 'B Label'
}
],
title: 'Trend Title',
x: {
title: 'Title X',
// key in the data
key: 'date'
},
y: {
title: 'Title Y',
key: 'yData'
}
}),
assembledData,
actualTrend;
view.render = jasmine.createSpy('render');
expect(view.render).not.toHaveBeenCalled();
// phantomjs doesn't have the bind method on function object
// (see https://github.com/novus/nvd3/issues/367) and nvd3 will
// throw an error when it tries to render (when trend data is set).
try {
model.set('trends', [{
date: '2014-01-01',
trendA: 10,
trendB: 0
}, {
date: '2014-01-02',
trendA: 20,
trendB: 1000
}]);
} catch (e) {
if (e.name !== 'TypeError') {
throw e;
}
}
// check the data passed to nvd3
assembledData = view.assembleTrendData();
expect(assembledData.length).toBe(2);
actualTrend = assembledData[0];
// 'key' is the title/label of the of the trend
expect(actualTrend.key).toBe('A Label');
expect(actualTrend.values.length).toBe(2);
expect(actualTrend.values).toContain({date: '2014-01-01', yData: 10});
expect(actualTrend.values).toContain({date: '2014-01-02', yData: 20});
expect(actualTrend.color).toBe('#8DA0CB');
actualTrend = assembledData[1];
expect(actualTrend.key).toBe('B Label');
expect(actualTrend.values.length).toBe(2);
expect(actualTrend.values).toContain({date: '2014-01-01', yData: 0});
expect(actualTrend.values).toContain({date: '2014-01-02', yData: 1000});
expect(actualTrend.color).toBeUndefined();
});
});
});
...@@ -2,57 +2,93 @@ define(['bootstrap', 'd3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views ...@@ -2,57 +2,93 @@ define(['bootstrap', 'd3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views
function (bootstrap, d3, $, nvd3, _, Utils, AttributeListenerView) { function (bootstrap, d3, $, nvd3, _, Utils, AttributeListenerView) {
'use strict'; 'use strict';
var EnrollmentTrendView = AttributeListenerView.extend({ var TrendsView = AttributeListenerView.extend({
initialize: function (options) { initialize: function (options) {
AttributeListenerView.prototype.initialize.call(this, options); AttributeListenerView.prototype.initialize.call(this, options);
var self = this; var self = this;
self.options = options;
self.renderIfDataAvailable(); self.renderIfDataAvailable();
}, },
/**
* Returns an array of maps of the trend data passed to nvd3 for
* rendering. The map consists of the trend data as 'values' and
* the trend title as 'key'.
*/
assembleTrendData: function() {
var self = this,
combinedTrends,
data = self.model.get(self.options.modelAttribute),
trendOptions = self.options.trends;
// parse and format the data for nvd3
combinedTrends = _(trendOptions).map( function (trendOption) {
var values = _(data).map(function (datum) {
var keyedValue = {},
yKey = trendOption.key || self.options.y.key;
keyedValue[self.options.y.key] = datum[yKey];
keyedValue[self.options.x.key] = datum[self.options.x.key];
return keyedValue;
});
return {
values: values,
// displayed trend label/title
key: trendOption.title,
// default color used if none specified
color: trendOption.color || undefined
};
});
return combinedTrends;
},
render: function () { render: function () {
AttributeListenerView.prototype.render.call(this); AttributeListenerView.prototype.render.call(this);
var self = this, var self = this,
canvas = d3.select(self.el), canvas = d3.select(self.el),
tooltipText = gettext('This graph displays total enrollment for the course calculated at the end of each day. Total enrollment includes new enrollments as well as un-enrollments.'),
tooltipTemplate = _.template('<i class="ico ico-tooltip fa fa-info-circle chart-tooltip" data-toggle="tooltip" data-placement="top" title="<%=text%>"></i>'), tooltipTemplate = _.template('<i class="ico ico-tooltip fa fa-info-circle chart-tooltip" data-toggle="tooltip" data-placement="top" title="<%=text%>"></i>'),
chart, chart,
title, title,
$tooltip; $tooltip;
chart = nvd3.models.lineChart() chart = nvd3.models.lineChart()
.margin({left: 80, right: 45}) // margins so text fits .margin({left: 80, right: 65}) // margins so text fits
.showLegend(true) .showLegend(true)
.useInteractiveGuideline(true) .useInteractiveGuideline(true)
.forceY(0) .forceY(0)
.x(function (d) { .x(function (d) {
// Parse dates to integers // Parse dates to integers
return Date.parse(d.date); return Date.parse(d[self.options.x.key]);
}) })
.y(function (d) { .y(function (d) {
// Simply return the count // Simply return the count
return d.count; return d[self.options.y.key];
}) })
.tooltipContent(function (key, y, e, graph) { .tooltipContent(function (key, y, e, graph) {
return '<h3>' + key + '</h3>'; return '<h3>' + key + '</h3>';
}); });
chart.xAxis chart.xAxis
.axisLabel('Date') .axisLabel(self.options.x.title)
.tickFormat(function (d) { .tickFormat(function (d) {
return Utils.formatDate(d); return Utils.formatDate(d);
}); });
chart.yAxis.axisLabel('Students'); chart.yAxis.axisLabel(self.options.y.title);
// Add the title title = canvas.attr('class', 'line-chart-container')
title = canvas.attr('class', 'line-chart-container').append('div'); .append('div')
title.attr('class', 'chart-title').text(gettext('Daily Student Enrollment')); .attr('class', 'chart-title')
.text(self.options.title);
// Add the tooltip // Add the tooltip
$tooltip = $(tooltipTemplate({text: tooltipText})); if(_(self.options).has('tooltip')) {
$(title[0]).append($tooltip); $tooltip = $(tooltipTemplate({text: self.options.tooltip}));
$tooltip.tooltip(); $(title[0]).append($tooltip);
$tooltip.tooltip();
}
// Append the svg to an inner container so that it adapts to // Append the svg to an inner container so that it adapts to
// the height of the inner container instead of the outer // the height of the inner container instead of the outer
...@@ -60,19 +96,16 @@ define(['bootstrap', 'd3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views ...@@ -60,19 +96,16 @@ define(['bootstrap', 'd3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views
canvas.append('div') canvas.append('div')
.attr('class', 'line-chart') .attr('class', 'line-chart')
.append('svg') .append('svg')
.datum([{ .datum(self.assembleTrendData())
values: self.model.get(self.modelAttribute),
key: gettext('Students')
}])
.call(chart); .call(chart);
nv.utils.windowResize(chart.update); nvd3.utils.windowResize(chart.update);
return this; return this;
} }
}); });
return EnrollmentTrendView; return TrendsView;
} }
); );
...@@ -7,7 +7,7 @@ django-model-utils==1.5.0 # BSD ...@@ -7,7 +7,7 @@ django-model-utils==1.5.0 # BSD
django_compressor==1.4 # MIT django_compressor==1.4 # MIT
logutils==0.3.3 # BSD logutils==0.3.3 # BSD
-e git+git@github.com:edx/python-social-auth.git@edx#egg=python-social-auth # BSD -e git+git@github.com:edx/python-social-auth.git@edx#egg=python-social-auth # BSD
-e git+git@github.com:edx/edx-analytics-data-api-client.git@0.2.0#egg=edx-analytics-data-api-client # edX -e git+git@github.com:edx/edx-analytics-data-api-client.git@0.2.1#egg=edx-analytics-data-api-client # edX
-e git+https://github.com/edx/django-lang-pref-middleware.git@0.1.0#egg=django-lang-pref-middleware -e git+https://github.com/edx/django-lang-pref-middleware.git@0.1.0#egg=django-lang-pref-middleware
django-waffle==0.10 # BSD django-waffle==0.10 # BSD
-e git+git@github.com:edx/i18n-tools.git@0d7847f9dfa2281640527b4dc51f5854f950f9b7#egg=i18n_tools -e git+git@github.com:edx/i18n-tools.git@0d7847f9dfa2281640527b4dc51f5854f950f9b7#egg=i18n_tools
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