Commit c8ed2cb5 by Dennis Jen

Merge pull request #92 from edx/dsjen/multi-trend-line

Engagement trend view and table displays engagement activities.
parents dca4d63a 7ef8949b
......@@ -47,6 +47,28 @@ class FooterMixin(object):
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):
url = '{}/test/auto_auth/'.format(server_url)
return browser.get(url)
import datetime
from bok_choy.web_app_test import WebAppTest
from bok_choy.promise import EmptyPromise
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
_multiprocess_can_split_ = True
class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, WebAppTest):
class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, CoursePageTestsMixin, WebAppTest):
"""
Tests for the Engagement page.
"""
......@@ -27,17 +28,19 @@ class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, WebAppTest):
def test_page_exists(self):
self.page.visit()
def test_student_activity(self):
def test_engagement_summary(self):
self.page.visit()
section_selector = "div[data-role=student-activity]"
section = self.page.q(css=section_selector)
self.assertTrue(section.present)
# Verify the week displayed
week = self.page.q(css=section_selector + ' span[data-role=activity-week]')
self.assertTrue(week.present)
expected = self.course.recent_activity(at.ANY)['interval_end']
expected = datetime.datetime.strptime(expected, "%Y-%m-%dT%H:%M:%SZ")
recent_activity = self.course.activity()[0]
expected = recent_activity['interval_end']
expected = datetime.datetime.strptime(expected, self.api_client.DATETIME_FORMAT)
expected = expected.strftime('%B %d, %Y')
self.assertEqual(week.text[0], expected)
......@@ -48,3 +51,46 @@ class CourseEngagementTests(AnalyticsApiClientMixin, FooterMixin, WebAppTest):
element = self.page.q(css=selector)
self.assertTrue(element.present)
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
from analyticsclient import demographic
from acceptance_tests import AnalyticsApiClientMixin, FooterMixin
from acceptance_tests import AnalyticsApiClientMixin, CoursePageTestsMixin, FooterMixin
from acceptance_tests.pages import CourseEnrollmentActivityPage, CourseEnrollmentGeographyPage
_multiprocess_can_split_ = True
class CourseEnrollmentTests(AnalyticsApiClientMixin, FooterMixin):
class CourseEnrollmentTests(AnalyticsApiClientMixin, FooterMixin, CoursePageTestsMixin):
"""
Tests for the Enrollment page.
"""
......@@ -22,23 +22,6 @@ class CourseEnrollmentTests(AnalyticsApiClientMixin, FooterMixin):
super(CourseEnrollmentTests, self).setUp()
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):
self.page.visit()
......
......@@ -4,22 +4,27 @@ from django.conf import settings
from analyticsclient.client import Client
import analyticsclient.activity_type as AT
from analyticsclient.exceptions import NotFoundError
from analyticsclient import demographic
from waffle import switch_is_active
class BasePresenter(object):
"""
This is the base class for the pages and sets up the analytics client
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
self.client = Client(base_url=settings.DATA_API_URL,
auth_token=settings.DATA_API_AUTH_TOKEN,
timeout=timeout)
self.course_id = course_id
self.course = self.client.courses(self.course_id)
self.date_format = Client.DATE_FORMAT
self.date_time_format = Client.DATETIME_FORMAT
def parse_date(self, date_string):
"""
......@@ -39,49 +44,74 @@ class BasePresenter(object):
"""
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):
"""
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.
Arguments:
course_id (str): ID of the course to retrieve summary information of.
Retrieve engagement activity trends for specified date range and return
results with zeros filled in for all activities.
"""
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
activities = [any_activity, ]
# fill in gaps in activity with zero for display (api doesn't return
# 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
# 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}
return trends
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
for activity in activities:
summary[activity['activity_type']] = activity['count']
# fill in gaps in the summary if no data found so we can display a proper message
activity_types = [AT.ANY, AT.ATTEMPTED_PROBLEM, AT.PLAYED_VIDEO, AT.POSTED_FORUM]
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
......@@ -89,18 +119,15 @@ class CourseEngagementPresenter(BasePresenter):
class CourseEnrollmentPresenter(BasePresenter):
""" Presenter for the course enrollment data. """
def __init__(self, course_id, timeout=5):
super(CourseEnrollmentPresenter, self).__init__(timeout)
self.course_id = course_id
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_trend_data(self, end_date=None, num_days=None):
start_date, end_date = self.get_date_range(end_date, num_days)
return self.course.enrollment(start_date=start_date, end_date=end_date)
def get_geography_data(self):
"""
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 = []
update_date = None
......@@ -122,9 +149,6 @@ class CourseEnrollmentPresenter(BasePresenter):
def get_summary(self):
"""
Returns the summary information for enrollments.
Arguments:
course_id (str): ID of the course to retrieve summary information of.
"""
# Establish default return values
......@@ -139,11 +163,9 @@ class CourseEnrollmentPresenter(BasePresenter):
if recent_enrollment:
# Get data for a month prior to most-recent data
days_in_week = 7
last_enrollment_date = self.parse_date(recent_enrollment[0]['date'])
month_before = last_enrollment_date - datetime.timedelta(days=31)
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)
last_week_enrollment = self.get_trend_data(end_date=last_enrollment_date, num_days=days_in_week)
# Add the first values to the returned data dictionary using the most-recent enrollment data
current_enrollment = recent_enrollment[0]['count']
......@@ -152,16 +174,14 @@ class CourseEnrollmentPresenter(BasePresenter):
'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
# the loop below.
num_days_of_data = len(last_month_enrollment)
# we could get fewer days of data than desired
num_days_of_data = len(last_week_enrollment)
# Get difference in enrollment for last week
interval = 7
count = None
if num_days_of_data > interval:
index = -interval - 1
count = current_enrollment - last_month_enrollment[index]['count']
data['enrollment_change_last_%s_days' % interval] = count
if num_days_of_data > days_in_week:
index = -days_in_week - 1
count = current_enrollment - last_week_enrollment[index]['count']
data['enrollment_change_last_%s_days' % days_in_week] = count
return data
{% extends "courses/base-course.html" %}
{% load i18n %}
{% load humanize %}
{% load staticfiles %}
{% load waffle_tags %}
......@@ -17,21 +18,34 @@ Individual course-centric engagement content view.
{% block content %}
<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="row">
<div class="col-sm-12">
<span class="section-title-value small">Activity for the week ending
<span data-role="activity-week">{{ summary.week_of_activity }}</span>
<span class="section-title-value small">{% trans "Activity for the week ending" %}
<span data-role="activity-week">{{ summary.week_of_activity|date}}</span>
</span>
</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="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 %}
......@@ -53,6 +67,22 @@ Individual course-centric engagement content view.
</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 %}
<hr/>
<div data-role="demo-interface">
......
import mock
import datetime
from waffle import Switch
from django.test import TestCase
import analyticsclient.activity_type as AT
from analyticsclient.exceptions import NotFoundError
from courses.presenters import CourseEngagementPresenter, CourseEnrollmentPresenter, BasePresenter
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
def mock_activity_data(activity_type):
if activity_type == AT.POSTED_FORUM:
raise NotFoundError
def mock_activity_data():
# AT.POSTED_FORUM deliberately left off to test edge case where activity is empty
activity_types = [AT.ANY, AT.ATTEMPTED_PROBLEM, AT.PLAYED_VIDEO]
activity_types = [AT.ANY, AT.ATTEMPTED_PROBLEM, AT.PLAYED_VIDEO, AT.POSTED_FORUM]
summaries = {}
summaries = {
'interval_end': '2014-01-01T000000',
}
count = 0
for activity in activity_types:
summaries[activity] = {
'interval_end': 'this is a time ' + str(count),
'activity_type': activity,
'count': 500 * count
}
summaries[activity] = 500 * count
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):
def setUp(self):
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):
summary = self.presenter.get_summary('this/course/id')
summary = self.presenter.get_summary()
# 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
self.assertEqual(summary[AT.ANY], 0)
......@@ -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
# 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):
def setUp(self):
self.presenter = BasePresenter()
self.presenter = BasePresenter('edX/DemoX/Demo_Course')
def test_init(self):
presenter = BasePresenter()
presenter = BasePresenter('edX/DemoX/Demo_Course')
self.assertEqual(presenter.client.timeout, 5)
presenter = BasePresenter(timeout=15)
presenter = BasePresenter('edX/DemoX/Demo_Course', timeout=15)
self.assertEqual(presenter.client.timeout, 15)
def test_parse_date(self):
......@@ -69,6 +105,10 @@ class BasePresenterTests(TestCase):
def test_format_date(self):
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):
def setUp(self):
......@@ -100,7 +140,7 @@ class CourseEnrollmentPresenterTests(TestCase):
def test_get_trend_data(self, mock_enrollment):
expected = get_mock_enrollment_data(self.course_id)
mock_enrollment.return_value = expected
actual = self.presenter.get_trend_data(self.course_id)
actual = self.presenter.get_trend_data()
self.assertEqual(actual, expected)
@mock.patch('analyticsclient.course.Course.enrollment')
......
......@@ -69,9 +69,47 @@ def set_empty_permissions(user):
def mock_engagement_summary_data():
return {
'interval_end': '2013-01-01T12:12:12Z',
'interval_end': datetime.date(year=2013, month=1, day=1),
AT.ANY: 100,
AT.ATTEMPTED_PROBLEM: 301,
AT.PLAYED_VIDEO: 1000,
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 = [
('overview', views.OverviewView.as_view()),
('csv/enrollment', views.CourseEnrollmentCSV.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+)'
......
......@@ -17,7 +17,7 @@ from analyticsclient.exceptions import NotFoundError
from courses import permissions
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):
......@@ -275,14 +275,8 @@ class EnrollmentActivityView(EnrollmentTemplateView):
presenter = CourseEnrollmentPresenter(self.course_id)
try:
summary = presenter.get_summary()
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
summary = presenter.get_summary()
data = presenter.get_trend_data(end_date=summary['date'])
# add the enrollment data for the page
context['js_data']['course']['enrollmentTrends'] = data
......@@ -306,11 +300,7 @@ class EnrollmentGeographyView(EnrollmentTemplateView):
context = super(EnrollmentGeographyView, self).get_context_data(**kwargs)
presenter = CourseEnrollmentPresenter(self.course_id)
try:
data, last_update = presenter.get_geography_data()
except NotFoundError:
raise Http404
data, last_update = presenter.get_geography_data()
context['js_data']['course']['enrollmentByCountry'] = data
context['js_data']['course']['enrollmentByCountryUpdateDate'] = get_formatted_date(last_update)
......@@ -339,10 +329,13 @@ class EngagementContentView(EngagementTemplateView):
'played_video_summary': _('Students who played one or more videos'),
}
presenter = CourseEngagementPresenter()
summary = presenter.get_summary(self.course_id)
presenter = CourseEngagementPresenter(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({
'tooltips': tooltips,
......@@ -395,6 +388,18 @@ class CourseEnrollmentCSV(CSVResponseMixin, CourseView):
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):
permanent = False
......
......@@ -6,5 +6,75 @@
require(['vendor/domReady!', 'load/init-page'], function(doc, page){
'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){
// this is your page specific code
require(['views/data-table-view',
'views/enrollment-trend-view'],
function (DataTableView, EnrollmentTrendView) {
'views/trends-view'],
function (DataTableView, TrendsView) {
// Daily enrollment graph
new EnrollmentTrendView({
new TrendsView({
el: '#enrollment-trend-view',
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
......
......@@ -12,6 +12,25 @@ define(['backbone', 'jquery'], function(Backbone, $) {
isEmpty: function() {
var self = this;
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) {
config.baseUrl + 'js/spec/specs/attribute-view-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/trends-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/utils-spec.js'
......
......@@ -12,5 +12,18 @@ define(['models/course-model'], function(CourseModel) {
expect(model.isEmpty()).toBe(false);
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
function (bootstrap, d3, $, nvd3, _, Utils, AttributeListenerView) {
'use strict';
var EnrollmentTrendView = AttributeListenerView.extend({
var TrendsView = AttributeListenerView.extend({
initialize: function (options) {
AttributeListenerView.prototype.initialize.call(this, options);
var self = this;
self.options = options;
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 () {
AttributeListenerView.prototype.render.call(this);
var self = this,
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>'),
chart,
title,
$tooltip;
chart = nvd3.models.lineChart()
.margin({left: 80, right: 45}) // margins so text fits
.margin({left: 80, right: 65}) // margins so text fits
.showLegend(true)
.useInteractiveGuideline(true)
.forceY(0)
.x(function (d) {
// Parse dates to integers
return Date.parse(d.date);
return Date.parse(d[self.options.x.key]);
})
.y(function (d) {
// Simply return the count
return d.count;
return d[self.options.y.key];
})
.tooltipContent(function (key, y, e, graph) {
return '<h3>' + key + '</h3>';
});
chart.xAxis
.axisLabel('Date')
.axisLabel(self.options.x.title)
.tickFormat(function (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').append('div');
title.attr('class', 'chart-title').text(gettext('Daily Student Enrollment'));
title = canvas.attr('class', 'line-chart-container')
.append('div')
.attr('class', 'chart-title')
.text(self.options.title);
// Add the tooltip
$tooltip = $(tooltipTemplate({text: tooltipText}));
$(title[0]).append($tooltip);
$tooltip.tooltip();
if(_(self.options).has('tooltip')) {
$tooltip = $(tooltipTemplate({text: self.options.tooltip}));
$(title[0]).append($tooltip);
$tooltip.tooltip();
}
// Append the svg to an inner container so that it adapts to
// the height of the inner container instead of the outer
......@@ -60,19 +96,16 @@ define(['bootstrap', 'd3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views
canvas.append('div')
.attr('class', 'line-chart')
.append('svg')
.datum([{
values: self.model.get(self.modelAttribute),
key: gettext('Students')
}])
.datum(self.assembleTrendData())
.call(chart);
nv.utils.windowResize(chart.update);
nvd3.utils.windowResize(chart.update);
return this;
}
});
return EnrollmentTrendView;
return TrendsView;
}
);
......@@ -7,7 +7,7 @@ django-model-utils==1.5.0 # BSD
django_compressor==1.4 # MIT
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/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
django-waffle==0.10 # BSD
-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