diff --git a/lms/djangoapps/discussion/views.py b/lms/djangoapps/discussion/views.py
index d3fbcce41f..06658be4af 100644
--- a/lms/djangoapps/discussion/views.py
+++ b/lms/djangoapps/discussion/views.py
@@ -483,6 +483,69 @@ def _create_discussion_board_context(request, base_context, thread=None):
return context
+def create_user_profile_context(request, course_key, user_id):
+ """ Generate a context dictionary for the user profile. """
+ user = cc.User.from_django_user(request.user)
+ course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
+
+ # If user is not enrolled in the course, do not proceed.
+ django_user = User.objects.get(id=user_id)
+ if not CourseEnrollment.is_enrolled(django_user, course.id):
+ raise Http404
+
+ query_params = {
+ 'page': request.GET.get('page', 1),
+ 'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
+ }
+
+ group_id = get_group_id_for_comments_service(request, course_key)
+ if group_id is not None:
+ query_params['group_id'] = group_id
+ profiled_user = cc.User(id=user_id, course_id=course_key, group_id=group_id)
+ else:
+ profiled_user = cc.User(id=user_id, course_id=course_key)
+
+ threads, page, num_pages = profiled_user.active_threads(query_params)
+ query_params['page'] = page
+ query_params['num_pages'] = num_pages
+
+ with function_trace("get_metadata_for_threads"):
+ user_info = cc.User.from_django_user(request.user).to_dict()
+ annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info)
+
+ is_staff = has_permission(request.user, 'openclose_thread', course.id)
+ threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads]
+ with function_trace("add_courseware_context"):
+ add_courseware_context(threads, course, request.user)
+
+ # TODO: LEARNER-3854: If we actually implement Learner Analytics code, this
+ # code was original protected to not run in user_profile() if is_ajax().
+ # Someone should determine if that is still necessary (i.e. was that ever
+ # called as is_ajax()) and clean this up as necessary.
+ user_roles = django_user.roles.filter(
+ course_id=course.id
+ ).order_by("name").values_list("name", flat=True).distinct()
+
+ with function_trace("get_cohort_info"):
+ course_discussion_settings = get_course_discussion_settings(course_key)
+ user_group_id = get_group_id_for_user(request.user, course_discussion_settings)
+
+ context = _create_base_discussion_view_context(request, course_key)
+ context.update({
+ 'django_user': django_user,
+ 'django_user_roles': user_roles,
+ 'profiled_user': profiled_user.to_dict(),
+ 'threads': threads,
+ 'user_group_id': user_group_id,
+ 'annotated_content_info': annotated_content_info,
+ 'page': query_params['page'],
+ 'num_pages': query_params['num_pages'],
+ 'sort_preference': user.default_sort_key,
+ 'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}),
+ })
+ return context
+
+
@require_GET
@login_required
@use_bulk_ops
@@ -491,75 +554,21 @@ def user_profile(request, course_key, user_id):
Renders a response to display the user profile page (shown after clicking
on a post author's username).
"""
- user = cc.User.from_django_user(request.user)
- course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
-
try:
- # If user is not enrolled in the course, do not proceed.
- django_user = User.objects.get(id=user_id)
- if not CourseEnrollment.is_enrolled(django_user, course.id):
- raise Http404
-
- query_params = {
- 'page': request.GET.get('page', 1),
- 'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
- }
-
- try:
- group_id = get_group_id_for_comments_service(request, course_key)
- except ValueError:
- return HttpResponseServerError("Invalid group_id")
- if group_id is not None:
- query_params['group_id'] = group_id
- profiled_user = cc.User(id=user_id, course_id=course_key, group_id=group_id)
- else:
- profiled_user = cc.User(id=user_id, course_id=course_key)
-
- threads, page, num_pages = profiled_user.active_threads(query_params)
- query_params['page'] = page
- query_params['num_pages'] = num_pages
-
- with function_trace("get_metadata_for_threads"):
- user_info = cc.User.from_django_user(request.user).to_dict()
- annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info)
-
- is_staff = has_permission(request.user, 'openclose_thread', course.id)
- threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads]
- with function_trace("add_courseware_context"):
- add_courseware_context(threads, course, request.user)
+ context = create_user_profile_context(request, course_key, user_id)
if request.is_ajax():
return utils.JsonResponse({
- 'discussion_data': threads,
- 'page': query_params['page'],
- 'num_pages': query_params['num_pages'],
- 'annotated_content_info': annotated_content_info,
+ 'discussion_data': context['threads'],
+ 'page': context['page'],
+ 'num_pages': context['num_pages'],
+ 'annotated_content_info': context['annotated_content_info'],
})
else:
- user_roles = django_user.roles.filter(
- course_id=course.id
- ).order_by("name").values_list("name", flat=True).distinct()
-
- with function_trace("get_cohort_info"):
- course_discussion_settings = get_course_discussion_settings(course_key)
- user_group_id = get_group_id_for_user(request.user, course_discussion_settings)
-
- context = _create_base_discussion_view_context(request, course_key)
- context.update({
- 'django_user': django_user,
- 'django_user_roles': user_roles,
- 'profiled_user': profiled_user.to_dict(),
- 'threads': threads,
- 'user_group_id': user_group_id,
- 'annotated_content_info': annotated_content_info,
- 'page': query_params['page'],
- 'num_pages': query_params['num_pages'],
- 'sort_preference': user.default_sort_key,
- 'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}),
- })
-
return render_to_response('discussion/discussion_profile_page.html', context)
except User.DoesNotExist:
raise Http404
+ except ValueError:
+ return HttpResponseServerError("Invalid group_id")
@login_required
diff --git a/lms/envs/common.py b/lms/envs/common.py
index c125e9e6f2..490379a156 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -2346,6 +2346,7 @@ INSTALLED_APPS = [
'openedx.features.course_search',
'openedx.features.enterprise_support.apps.EnterpriseSupportConfig',
'openedx.features.learner_profile',
+ 'openedx.features.learner_analytics',
'experiments',
diff --git a/lms/static/images/learner_analytics_dashboard/analytics-grading.png b/lms/static/images/learner_analytics_dashboard/analytics-grading.png
new file mode 100644
index 0000000000..bc91300b44
Binary files /dev/null and b/lms/static/images/learner_analytics_dashboard/analytics-grading.png differ
diff --git a/lms/static/images/learner_analytics_dashboard/streak-1140x768.jpg b/lms/static/images/learner_analytics_dashboard/streak-1140x768.jpg
new file mode 100644
index 0000000000..0568984f70
Binary files /dev/null and b/lms/static/images/learner_analytics_dashboard/streak-1140x768.jpg differ
diff --git a/lms/static/images/learner_analytics_dashboard/streak-768x517.jpg b/lms/static/images/learner_analytics_dashboard/streak-768x517.jpg
new file mode 100644
index 0000000000..37f88736ba
Binary files /dev/null and b/lms/static/images/learner_analytics_dashboard/streak-768x517.jpg differ
diff --git a/lms/static/images/learner_analytics_dashboard/streak-trophy.png b/lms/static/images/learner_analytics_dashboard/streak-trophy.png
new file mode 100644
index 0000000000..8bc6fa887b
Binary files /dev/null and b/lms/static/images/learner_analytics_dashboard/streak-trophy.png differ
diff --git a/lms/static/js/learner_analytics_dashboard/CircleChart.jsx b/lms/static/js/learner_analytics_dashboard/CircleChart.jsx
new file mode 100644
index 0000000000..fe59464912
--- /dev/null
+++ b/lms/static/js/learner_analytics_dashboard/CircleChart.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+
+const size = 100;
+const radCircumference = Math.PI * 2;
+const center = size / 2;
+const radius = center - 1; // padding to prevent clipping
+
+// Based on https://github.com/brigade/react-simple-pie-chart
+class CircleChart extends React.Component {
+ constructor(props) {
+ super(props);
+ this.getCenter = this.getCenter.bind(this);
+ this.getSlices = this.getSlices.bind(this);
+ }
+
+ getCenter() {
+ const {centerHole, sliceBorder} = this.props;
+ if (centerHole) {
+ const size = center / 2;
+ return (
+
+ );
+ }
+ }
+
+ getSlices(slices, sliceBorder) {
+ const total = slices.reduce((totalValue, { value }) => totalValue + value, 0);
+ const {strokeColor, strokeWidth} = sliceBorder;
+
+ let radSegment = 0;
+ let lastX = radius;
+ let lastY = 0;
+
+ // Reverse a copy of the array so order start at 12 o'clock
+ return slices.slice(0).reverse().map(({ value, sliceIndex }, index) => {
+ // Should we just draw a circle?
+ if (value === total) {
+ return (
+
+ );
+ }
+
+ if (value === 0) {
+ return;
+ }
+
+ const valuePercentage = value / total;
+
+ // Should the arc go the long way round?
+ const longArc = (valuePercentage <= 0.5) ? 0 : 1;
+
+ radSegment += valuePercentage * radCircumference;
+ const nextX = Math.cos(radSegment) * radius;
+ const nextY = Math.sin(radSegment) * radius;
+
+ /**
+ * d is a string that describes the path of the slice.
+ * The weirdly placed minus signs [eg, (-(lastY))] are due to the fact
+ * that our calculations are for a graph with positive Y values going up,
+ * but on the screen positive Y values go down.
+ */
+ const d = [
+ `M ${center},${center}`,
+ `l ${lastX},${-lastY}`,
+ `a${radius},${radius}`,
+ '0',
+ `${longArc},0`,
+ `${nextX - lastX},${-(nextY - lastY)}`,
+ 'z',
+ ].join(' ');
+
+ lastX = nextX;
+ lastY = nextY;
+
+ return ;
+ });
+ }
+
+ render() {
+ const {slices, sliceBorder} = this.props;
+
+ return (
+
+
+ {this.getSlices(slices, sliceBorder)}
+
+ {this.getCenter()}
+
+ );
+ }
+}
+
+CircleChart.defaultProps = {
+ sliceBorder: {
+ strokeColor: '#fff',
+ strokeWidth: 0
+ }
+};
+
+CircleChart.propTypes = {
+ slices: PropTypes.array.isRequired,
+ centerHole: PropTypes.bool,
+ sliceBorder: PropTypes.object
+};
+
+export default CircleChart;
diff --git a/lms/static/js/learner_analytics_dashboard/CircleChartLegend.jsx b/lms/static/js/learner_analytics_dashboard/CircleChartLegend.jsx
new file mode 100644
index 0000000000..73a2e58ae6
--- /dev/null
+++ b/lms/static/js/learner_analytics_dashboard/CircleChartLegend.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+
+class CircleChartLegend extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ getList() {
+ const {data} = this.props;
+
+ return data.map(({ value, label, sliceIndex }, index) => {
+ const swatchClass = `swatch-${sliceIndex}`;
+ return (
+
+
+ {label}
+ {this.getPercentage(value)}
+
+ );
+ });
+ }
+
+ getPercentage(value) {
+ const num = value * 100;
+
+ return `${num}%`;
+ }
+
+ renderList() {
+ return (
+
+ );
+ }
+
+ render() {
+ return (
+
+ {this.renderList()}
+
+ );
+ }
+}
+
+
+CircleChartLegend.propTypes = {
+ data: PropTypes.array.isRequired
+}
+
+export default CircleChartLegend;
diff --git a/lms/static/js/learner_analytics_dashboard/Discussions.jsx b/lms/static/js/learner_analytics_dashboard/Discussions.jsx
new file mode 100644
index 0000000000..a2ae32fb0c
--- /dev/null
+++ b/lms/static/js/learner_analytics_dashboard/Discussions.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+
+class Discussions extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ getComparisons() {
+ const {content_authored, profileImages} = this.props;
+ const content_average = 7;
+ let average_percent = 1;
+ let authored_percent = 0;
+
+ if (content_average > content_authored) {
+ average_percent = 1;
+ authored_percent = content_authored / content_average;
+ } else {
+ authored_percent = 1;
+ average_percent = content_average / content_authored;
+ }
+
+ return (
+
+ {this.getCountChart(content_authored, authored_percent, 'You', profileImages.medium)}
+ {this.getCountChart(content_average, average_percent, 'Average graduate')}
+
+ );
+ }
+
+ getCountChart(count, percent, label, img = false) {
+ return (
+
+ );
+ }
+
+ render() {
+ const {content_authored, thread_votes} = this.props;
+
+ return (
+
+
Discussions
+
+
Posts, comments, and replies
+ {this.getComparisons()}
+
+
+
+
+
{thread_votes}
+
Votes on your posts, comments, and replies
+
+
+
+ );
+ }
+}
+
+
+Discussions.propTypes = {
+ content_authored: PropTypes.number.isRequired,
+ thread_votes: PropTypes.number.isRequired
+}
+
+export default Discussions;
diff --git a/lms/static/js/learner_analytics_dashboard/DueDates.jsx b/lms/static/js/learner_analytics_dashboard/DueDates.jsx
new file mode 100644
index 0000000000..2700a08486
--- /dev/null
+++ b/lms/static/js/learner_analytics_dashboard/DueDates.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+
+const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+class DueDates extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ getDate(str) {
+ const date = new Date(str);
+ const day = days[date.getDay()];
+ const month = months[date.getMonth()];
+ const number = date.getDate();
+ const year = date.getFullYear();
+
+ return `${day} ${month} ${number}, ${year}`;
+ }
+
+ getLabel(type) {
+ const {assignmentCounts} = this.props;
+ if (assignmentCounts[type] < 2 ) {
+ return type;
+ } else {
+ this.renderLabels[type] += 1;
+ return type + ' ' + this.renderLabels[type];
+ }
+ }
+
+ getList() {
+ const {dates, assignmentCounts} = this.props;
+ this.renderLabels = this.initLabelTracker(assignmentCounts);
+
+ return dates.sort((a, b) => new Date(a.due) > new Date(b.due))
+ .map(({ format, due }, index) => {
+ return (
+
+ {this.getLabel(format)}
+ {this.getDate(due)}
+
+ );
+ });
+ }
+
+ initLabelTracker(list) {
+ let labels = Object.keys(list);
+
+ return labels.reduce((accumulator, key) => {
+ accumulator[key] = 0;
+ return accumulator;
+ }, {})
+ }
+
+ renderList() {
+ return (
+
+ );
+ }
+
+ render() {
+ return (
+
+ {this.renderList()}
+
+ );
+ }
+}
+
+
+DueDates.propTypes = {
+ dates: PropTypes.array.isRequired
+}
+
+export default DueDates;
diff --git a/lms/static/js/learner_analytics_dashboard/GradeTable.jsx b/lms/static/js/learner_analytics_dashboard/GradeTable.jsx
new file mode 100644
index 0000000000..b0bbca0fb1
--- /dev/null
+++ b/lms/static/js/learner_analytics_dashboard/GradeTable.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+const exGrades = [
+ {
+ "assignment_type":"Exam",
+ "total_possible":6.0,
+ "total_earned":3.0
+ },
+ {
+ "assignment_type":"Homework",
+ "total_possible":5.0,
+ },
+ {
+ "assignment_type":"Homework",
+ "total_possible":11.0,
+ "total_earned":0.0
+ }
+];
+
+class GradeTable extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ getTableGroup(type, groupIndex) {
+ const {grades} = this.props;
+ const groupData = grades.filter(value => {
+ if (value['assignment_type'] === type) {
+ return value;
+ }
+ });
+ const multipleAssessments = groupData.length > 1;
+
+ const rows = groupData.map(({assignment_type, total_possible, total_earned, passing_grade}, index) => {
+ const label = multipleAssessments ? `${assignment_type} ${index + 1}` : assignment_type;
+ return (
+
+ {label}
+ {passing_grade}/{total_possible}
+ {total_earned <= 0 ? '-' : total_earned}/{total_possible}
+
+ );
+ });
+
+ return {rows} ;
+ }
+
+ render() {
+ const {assignmentTypes} = this.props;
+ return (
+
+
+
+ Assessment
+ Passing
+ You
+
+
+ {assignmentTypes.map((type, index) => this.getTableGroup(type, index))}
+
+ )
+ }
+};
+
+GradeTable.propTypes = {
+ assignmentTypes: PropTypes.array.isRequired,
+ grades: PropTypes.array.isRequired
+}
+
+export default GradeTable;
diff --git a/lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx b/lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx
new file mode 100644
index 0000000000..acba4da9b8
--- /dev/null
+++ b/lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx
@@ -0,0 +1,127 @@
+/* global gettext */
+
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import CircleChart from './CircleChart';
+import CircleChartLegend from './CircleChartLegend';
+import GradeTable from './GradeTable';
+import DueDates from './DueDates';
+import Discussions from './Discussions';
+
+function arrayToObject(array) {
+ return array.reduce((accumulator, obj) => {
+ const key = Object.keys(obj)[0];
+ accumulator[key] = obj[key];
+ return accumulator;
+ }, {})
+}
+
+function countByType(type, assignments) {
+ let count = 0;
+ assignments.map(({format}) => {
+ if (format === type) {
+ count += 1;
+ }
+ })
+ return count;
+}
+
+function getActiveUserString(count) {
+ const users = (count === 1) ? 'User' : 'Users';
+ return `${users} active in this course right now`;
+}
+
+function getAssignmentCounts(types, assignments) {
+ const countsArray = types.map((type) => {
+ return {
+ [type]: countByType(type, assignments)
+ }
+ });
+
+ return arrayToObject(countsArray);
+}
+
+function getStreakIcons(count) {
+ return Array.apply(null, { length: count }).map((e, i) => (
+
+ ));
+}
+
+function getStreakString(count) {
+ const unit = (count ===1) ? 'week' : 'weeks';
+ return `Logged in ${count} ${unit} in a row`;
+}
+
+export function LearnerAnalyticsDashboard(props) {
+ const {grading_policy, grades, schedule, week_streak, weekly_active_users, discussion_info, profile_images} = props;
+ // temp. for local dev
+ // const week_streak = 3;
+ // const weekly_active_users = 83400;
+ const gradeBreakdown = grading_policy.GRADER.map(({type, weight}, index) => {
+ return {
+ value: weight,
+ label: type,
+ sliceIndex: index + 1
+ }
+ });
+
+ // Get a list of assignment types minus duplicates
+ const assignments = gradeBreakdown.map(value => value['label']);
+ const assignmentTypes = [...new Set(assignments)];
+ const assignmentCounts = getAssignmentCounts(assignmentTypes, schedule);
+
+ return (
+
+
+
+
Grading
+ {gradeBreakdown &&
+
Weight
+ }
+ {gradeBreakdown &&
+
+ }
+
+
Graded Assessments
+
+
+
+
+
+
+
+
+
+
Timing
+
Course due dates
+
+ {week_streak > 0 &&
+
+
{getStreakIcons(week_streak)}
+
Week streak
+
{getStreakString(week_streak)}
+
+ }
+
+
+
{weekly_active_users.toLocaleString('en', {useGrouping:true})}
+
{getActiveUserString(weekly_active_users)}
+
+
+
+ );
+}
+
diff --git a/lms/static/sass/_build-lms-v2.scss b/lms/static/sass/_build-lms-v2.scss
index 66d40d9b39..180b029bca 100644
--- a/lms/static/sass/_build-lms-v2.scss
+++ b/lms/static/sass/_build-lms-v2.scss
@@ -32,6 +32,7 @@
@import 'features/course-search';
@import 'features/course-sock';
@import 'features/course-upgrade-message';
+@import 'features/learner-analytics-dashboard';
// Responsive Design
@import 'header';
diff --git a/lms/static/sass/features/_learner-analytics-dashboard.scss b/lms/static/sass/features/_learner-analytics-dashboard.scss
new file mode 100644
index 0000000000..267459bc20
--- /dev/null
+++ b/lms/static/sass/features/_learner-analytics-dashboard.scss
@@ -0,0 +1,399 @@
+$mint-green: #92c9d3;
+$slices: #386f77, #1abc9c, $mint-green, #397d1c, #a39d3d, #0a5aaa;
+$border-color: #4a4a4a;
+$table-border: rgba(155, 155, 155, 0.2);
+$font-color: #636c72;
+$heading-color: #292b2c;
+$grouping-background: #f5f5f5;
+$grouping-border: #d8d8d8;
+$trophy-gold: #f39c12;
+
+.content-wrapper {
+ .page-content.learner-analytics-dashboard-wrapper {
+ flex-direction: column;
+ background: #fff;
+ }
+}
+
+.learner-analytics-dashboard {
+ .sidebar {
+ position: relative;
+
+ &::before {
+ content: '';
+ width: 100%;
+ height: 100px;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ background: {
+ image: url('#{$static-path}/images/learner_analytics_dashboard/streak-768x517.jpg');
+ repeat: no-repeat;
+ position: center;
+ }
+ }
+ &::after {
+ content: '';
+ width: 151px;
+ height: 192px;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ background: {
+ image: url('#{$static-path}/images/learner_analytics_dashboard/streak-trophy.png');
+ repeat: no-repeat;
+ position: bottom right;
+ }
+ }
+ }
+
+ .analytics-group {
+ background: $grouping-background;
+ border: 1px solid $grouping-border;
+ padding: 20px 20px 120px;
+ margin: 20px 0 40px;
+
+ &:last-of-type {
+ margin-bottom: 20px;
+ }
+
+ .group-heading {
+ color: $heading-color;
+ font-size: 1.5em;
+ }
+
+ .section-heading {
+ color: $heading-color;
+ font: {
+ size: 1em;
+ weight: bold;
+ }
+ }
+ }
+
+ .grading-weight-wrapper {
+ display: flex;
+ flex-flow: row wrap;
+ border-bottom: 1px solid $border-color;
+ .chart-wrapper {
+ width: 90px;
+ height: 90px;
+ margin-bottom: 20px;
+
+ .slice-1 {
+ fill: nth($slices, 1);
+ }
+ .slice-2 {
+ fill: nth($slices, 2);
+ }
+ .slice-3 {
+ fill: nth($slices, 3);
+ }
+ .slice-4 {
+ fill: nth($slices, 4);
+ }
+ .slice-5 {
+ fill: nth($slices, 5);
+ }
+ .slice-6 {
+ fill: nth($slices, 6);
+ }
+ }
+ }
+
+ .legend {
+ .legend-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 20px 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .legend-item {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-bottom: 10px;
+ font-size: 0.875em;
+
+ .color-swatch {
+ width: 20px;
+ height: 20px;
+ border-radius: 3px;
+ margin-right: 10px;
+
+ &.swatch-1 {
+ background-color: nth($slices, 1);
+ }
+ &.swatch-2 {
+ background-color: nth($slices, 2);
+ }
+ &.swatch-3 {
+ background-color: nth($slices, 3);
+ }
+ &.swatch-4 {
+ background-color: nth($slices, 4);
+ }
+ &.swatch-5 {
+ background-color: nth($slices, 5);
+ }
+ &.swatch-6 {
+ background-color: nth($slices, 6);
+ }
+ }
+
+ .label,
+ .percentage {
+ color: $font-color;
+ }
+
+ .label {
+ margin-right: 30px;
+ }
+
+ .percentage {
+ margin-left: auto;
+ }
+ }
+ }
+
+ .graded-assessments-wrapper {
+ .grade-table {
+ width: 100%;
+ margin-bottom: 30px;
+ font-size: 0.875em;
+
+ th {
+ font-weight: bold;
+
+ &:nth-of-type(3) {
+ width: 80px;
+ }
+ }
+
+ th,
+ td {
+ border: none;
+ color: $font-color;
+
+ &:nth-of-type(2),
+ &:nth-of-type(3) {
+ text-align: right;
+ }
+
+ &:nth-of-type(3) {
+ background: rgba(255, 193, 7, 0.15);
+ color: #000;
+ font-weight: bold;
+ }
+ }
+ }
+
+ .footnote {
+ font-size: 0.875em;
+ color: $font-color;
+ }
+
+ .table-head {
+ border-bottom: 2px solid $table-border;
+ }
+
+ .type-group {
+ border-bottom: 1px solid $table-border;
+
+ &:last-of-type {
+ border-bottom-width: 2px;
+ }
+ }
+ }
+
+ .due-dates {
+ border-bottom: 1px solid $table-border;
+ padding-bottom: 30px;
+ margin-bottom: 30px;
+
+ .date-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+
+ .date-item {
+ display: flex;
+ justify-content: space-between;
+ color: $font-color;
+ }
+ }
+ }
+
+ .week-streak-wrapper {
+ color: $font-color;
+
+ .streak-icon-wrapper {
+ margin-bottom: 10px;
+ }
+
+ .fa-trophy {
+ color: $trophy-gold;
+ font-size: 21px;
+ margin-right: 5px;
+ }
+
+ .section-heading {
+ margin-bottom: 0;
+ }
+ }
+
+ .active-users-wrapper {
+ .fa-user {
+ border: 2px solid $mint-green;
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
+ text-align: center;
+ position: relative;
+
+ &::after {
+ content: '';
+ height: 2px;
+ width: 12px;
+ position: absolute;
+ background-color: $mint-green;
+ left: 6px;
+ bottom: 0;
+ }
+ }
+
+
+ .label {
+ color: $font-color;
+ }
+ }
+
+ .discussions-wrapper {
+ display: flex;
+ flex-flow: column wrap;
+ .group-heading {
+ width: 100%;
+ }
+
+ .post-counts {
+ border-top: 1px solid $table-border;
+ }
+ }
+
+ .count-icon {
+ color: $mint-green;
+ font-size: 24px;
+ }
+
+ .user-count {
+ font-size: 1.5em;
+ margin-left: 10px;
+ }
+
+ .count-chart {
+ position: relative;
+ min-height: 50px;
+ margin-bottom: 20px;
+ padding-left: 70px;
+
+ .chart-bar {
+ height: 10px;
+ border-radius: 2px;
+ background: $mint-green;
+ }
+
+ .chart-icon {
+ width: 50px;
+ height: 50px;
+ background: $mint-green;
+ border-radius: 50%;
+ text-align: center;
+ font-size: 24px;
+ padding-top: 13px;
+ color: white;
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+
+ &:first-of-type {
+ .chart-bar,
+ .chart-icon {
+ background: $trophy-gold;
+ }
+ }
+
+ .chart-display {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: baseline;
+
+ .user-count {
+ margin-top: -10px;
+ }
+ }
+ }
+
+ @include media-breakpoint-up(md) {
+ .sidebar {
+ &::before {
+ background-image: url('#{$static-path}/images/learner_analytics_dashboard/streak-1140x768.jpg');
+ }
+ }
+
+ .analytics-group {
+ &:first-of-type {
+ background: {
+ image: url('#{$static-path}/images/learner_analytics_dashboard/analytics-grading.png');
+ repeat: no-repeat;
+ position: top right;
+ }
+ }
+ }
+ }
+
+ @include media-breakpoint-up(lg) {
+ .discussions-wrapper {
+ flex-direction: row;
+
+ .comparison-charts {
+ border-right: 1px solid $table-border;
+ width: 50%;
+ }
+
+ .post-counts {
+ border: none;
+ width: 50%;
+ padding-left: 20px;
+ }
+ }
+ }
+
+ @include media-breakpoint-up(xl) {
+ .sidebar {
+ &::before {
+ background-image: url('#{$static-path}/images/learner_analytics_dashboard/streak-768x517.jpg');
+ }
+ }
+
+ .learner-analytics-wrapper {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+
+ .main-block {
+ width: calc(100% - 390px);
+ }
+
+ .sidebar {
+ width: 360px;
+ margin-left: 30px;
+ }
+ }
+ }
+}
+
diff --git a/lms/urls.py b/lms/urls.py
index 05a3f27b9e..159f23126f 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -672,6 +672,14 @@ urlpatterns += [
r'^u/',
include('openedx.features.learner_profile.urls'),
),
+
+ # Learner analytics dashboard
+ url(
+ r'^courses/{}/learner_analytics/'.format(
+ settings.COURSE_ID_PATTERN,
+ ),
+ include('openedx.features.learner_analytics.urls'),
+ ),
]
if settings.FEATURES['ENABLE_TEAMS']:
diff --git a/openedx/features/learner_analytics/__init__.py b/openedx/features/learner_analytics/__init__.py
new file mode 100644
index 0000000000..51324589cf
--- /dev/null
+++ b/openedx/features/learner_analytics/__init__.py
@@ -0,0 +1,10 @@
+"""
+Learner analytics helpers and settings
+"""
+from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace
+
+
+# Namespace for learner analytics waffle flags.
+WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='learner_analytics')
+
+ENABLE_DASHBOARD_TAB = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'enable_dashboard_tab')
diff --git a/openedx/features/learner_analytics/templates/learner_analytics/dashboard.html b/openedx/features/learner_analytics/templates/learner_analytics/dashboard.html
new file mode 100644
index 0000000000..8b1b983c30
--- /dev/null
+++ b/openedx/features/learner_analytics/templates/learner_analytics/dashboard.html
@@ -0,0 +1,68 @@
+## mako
+<%page expression_filter="h"/>
+
+<%! main_css = "style-main-v2" %>
+
+<%inherit file="../main.html" />
+<%namespace name='static' file='../static_content.html'/>
+<%def name="online_help_token()"><% return "courseware" %>%def>
+<%def name="course_name()">
+<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
+%def>
+
+<%!
+import json
+from django.utils.translation import ugettext as _
+
+from openedx.core.djangolib.markup import HTML
+from openedx.features.course_experience import course_home_page_title
+%>
+
+<%block name="bodyclass">course%block>
+
+<%block name="pagetitle">${course_name()}%block>
+
+<%include file="../courseware/course_navigation.html" args="active_page='learner_analytics'" />
+
+<%block name="content">
+
+
+
+
+ ${static.renderReact(
+ component="LearnerAnalyticsDashboard",
+ id="react-learner-analytics-dashboard",
+ props={
+ 'schedule': assignment_schedule,
+ 'grading_policy': grading_policy,
+ 'grades': assignment_grades,
+ 'discussion_info': discussion_info,
+ 'weekly_active_users': weekly_active_users,
+ 'week_streak': week_streak,
+ 'profile_images': profile_image_urls,
+ }
+ )}
+
+
+
+%block>
+<%namespace name='static' file='../static_content.html'/>
diff --git a/openedx/features/learner_analytics/tests.py b/openedx/features/learner_analytics/tests.py
new file mode 100644
index 0000000000..3f734ee41e
--- /dev/null
+++ b/openedx/features/learner_analytics/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# TODO: LEARNER-3854: Implement tests or remove file after Learner Analytics test.
diff --git a/openedx/features/learner_analytics/urls.py b/openedx/features/learner_analytics/urls.py
new file mode 100644
index 0000000000..becb96d3c7
--- /dev/null
+++ b/openedx/features/learner_analytics/urls.py
@@ -0,0 +1,15 @@
+"""
+Url setup for learner analytics
+"""
+from django.conf.urls import url
+
+from views import LearnerAnalyticsView
+
+
+urlpatterns = [
+ url(
+ r'^$',
+ LearnerAnalyticsView.as_view(),
+ name='openedx.learner_analytics.dashboard',
+ ),
+]
diff --git a/openedx/features/learner_analytics/views.py b/openedx/features/learner_analytics/views.py
new file mode 100644
index 0000000000..3bb82169e3
--- /dev/null
+++ b/openedx/features/learner_analytics/views.py
@@ -0,0 +1,297 @@
+"""
+Learner analytics dashboard views
+"""
+import logging
+import math
+import urllib
+from datetime import datetime, timedelta
+
+import pytz
+import requests
+from analyticsclient.client import Client
+from django.conf import settings
+from django.contrib.auth.decorators import login_required
+from django.core.cache import cache
+from django.core.urlresolvers import reverse
+from django.http import Http404
+from django.shortcuts import render_to_response
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_control
+from django.views.generic import View
+from opaque_keys.edx.keys import CourseKey
+from student.models import CourseEnrollment
+from util.views import ensure_valid_course_key
+from xmodule.modulestore.django import modulestore
+
+from lms.djangoapps.course_api.blocks.api import get_blocks
+from lms.djangoapps.courseware.courses import get_course_with_access
+from lms.djangoapps.discussion.views import create_user_profile_context
+from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
+from openedx.features.course_experience import default_course_url_name
+from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user
+
+from . import ENABLE_DASHBOARD_TAB
+
+log = logging.getLogger(__name__)
+
+
+class LearnerAnalyticsView(View):
+ """
+ Displays the Learner Analytics Dashboard.
+ """
+ def __init__(self):
+ View.__init__(self)
+ self.analytics_client = Client(base_url=settings.ANALYTICS_API_URL, auth_token=settings.ANALYTICS_API_KEY)
+
+ @method_decorator(login_required)
+ @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
+ @method_decorator(ensure_valid_course_key)
+ def get(self, request, course_id):
+ """
+ Displays the user's Learner Analytics for the specified course.
+
+ Arguments:
+ request: HTTP request
+ course_id (unicode): course id
+ """
+ course_key = CourseKey.from_string(course_id)
+ if not ENABLE_DASHBOARD_TAB.is_enabled(course_key):
+ raise Http404
+
+ course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
+ course_url_name = default_course_url_name(course.id)
+ course_url = reverse(course_url_name, kwargs={'course_id': unicode(course.id)})
+
+ grading_policy = course.grading_policy
+
+ (grade_data, answered_percent) = self.get_grade_data(request.user, course_key, grading_policy['GRADE_CUTOFFS'])
+ schedule_data = self.get_assignments_with_due_date(request, course_key)
+ (grade_data, schedule_data) = self.sort_grade_and_schedule_data(grade_data, schedule_data)
+
+ context = {
+ 'course': course,
+ 'course_url': course_url,
+ 'disable_courseware_js': True,
+ 'uses_pattern_library': True,
+ 'is_self_paced': course.self_paced,
+ 'is_verified': CourseEnrollment.is_enrolled_as_verified(request.user, course_key),
+ 'grading_policy': grading_policy,
+ 'assignment_grades': grade_data,
+ 'answered_percent': answered_percent,
+ 'assignment_schedule': schedule_data,
+ 'profile_image_urls': get_profile_image_urls_for_user(request.user, request),
+ 'discussion_info': self.get_discussion_data(request, course_key),
+ 'weekly_active_users': self.get_weekly_course_activity_count(course_key),
+ 'week_streak': self.consecutive_weeks_of_course_activity_for_user(
+ request.user.username, course_key
+ )
+ }
+
+ return render_to_response('learner_analytics/dashboard.html', context)
+
+ def get_grade_data(self, user, course_key, grade_cutoffs):
+ """
+ Collects and formats the grades data for a particular user and course.
+
+ Args:
+ user (User)
+ course_key (CourseKey)
+ grade_cutoffs: # TODO: LEARNER-3854: Complete docstring if implementing Learner Analytics.
+ """
+ course_grade = CourseGradeFactory().read(user, course_key=course_key)
+ grades = []
+ total_earned = 0
+ total_possible = 0
+ answered_percent = None
+ for (location, subsection_grade) in course_grade.subsection_grades.iteritems():
+ if subsection_grade.format is not None:
+ possible = subsection_grade.graded_total.possible
+ earned = subsection_grade.graded_total.earned
+ passing_grade = math.ceil(possible * grade_cutoffs['Pass'])
+ grades.append({
+ 'assignment_type': subsection_grade.format,
+ 'total_earned': earned,
+ 'total_possible': possible,
+ 'passing_grade': passing_grade,
+ 'location': unicode(location),
+ 'assigment_url': reverse('jump_to_id', kwargs={
+ 'course_id': unicode(course_key),
+ 'module_id': unicode(location),
+ })
+ })
+ if earned > 0:
+ total_earned += earned
+ total_possible += possible
+
+ if total_possible > 0:
+ answered_percent = float(total_earned) / total_possible
+ return (grades, answered_percent)
+
+ def sort_grade_and_schedule_data(self, grade_data, schedule_data):
+ """
+ Sort the assignments in grade_data and schedule_data to be in the same order.
+ """
+ schedule_dict = {assignment['location']: assignment for assignment in schedule_data}
+
+ sorted_schedule_data = []
+ sorted_grade_data = []
+ for grade in grade_data:
+ assignment = schedule_dict.get(grade['location'])
+ if assignment:
+ sorted_grade_data.append(grade)
+ sorted_schedule_data.append(assignment)
+
+ return sorted_grade_data, sorted_schedule_data
+
+ def get_discussion_data(self, request, course_key):
+ """
+ Collects and formats the discussion data from a particular user and course.
+
+ Args:
+ request (HttpRequest)
+ course_key (CourseKey)
+ """
+ context = create_user_profile_context(request, course_key, request.user.id)
+ threads = context['threads']
+ profiled_user = context['profiled_user']
+
+ # TODO: LEARNER-3854: If implementing Learner Analytics, rename to content_authored_count.
+ content_authored = profiled_user['threads_count'] + profiled_user['comments_count']
+ thread_votes = 0
+ for thread in threads:
+ if thread['user_id'] == profiled_user['external_id']:
+ thread_votes += thread['votes']['count']
+ discussion_data = {
+ 'content_authored': content_authored,
+ 'thread_votes': thread_votes,
+ }
+ return discussion_data
+
+ def get_assignments_with_due_date(self, request, course_key):
+ """
+ Returns a list of assignment (graded) blocks with due dates, including
+ due date and location.
+
+ Args:
+ request (HttpRequest)
+ course_key (CourseKey)
+ """
+ course_usage_key = modulestore().make_course_usage_key(course_key)
+ all_blocks = get_blocks(
+ request,
+ course_usage_key,
+ user=request.user,
+ nav_depth=3,
+ requested_fields=['display_name', 'due', 'graded', 'format'],
+ block_types_filter=['sequential']
+ )
+ assignment_blocks = []
+ for (location, block) in all_blocks['blocks'].iteritems():
+ if block.get('graded', False) and block.get('due') is not None:
+ assignment_blocks.append(block)
+ block['due'] = block['due'].isoformat()
+ block['location'] = unicode(location)
+
+ return assignment_blocks
+
+ def get_weekly_course_activity_count(self, course_key):
+ """
+ Get the count of any course activity (total for all users) from previous 7 days.
+
+ Args:
+ course_key (CourseKey)
+ """
+ cache_key = 'learner_analytics_{course_key}_weekly_activities'.format(course_key=course_key)
+ activities = cache.get(cache_key)
+
+ if not activities:
+ log.info('Weekly course activities for course {course_key} was not cached - fetching from Analytics API'
+ .format(course_key=course_key))
+ weekly_course_activities = self.analytics_client.courses(course_key).activity()
+
+ if not weekly_course_activities or 'any' not in weekly_course_activities[0]:
+ return 0
+
+ # weekly course activities should only have one item
+ activities = weekly_course_activities[0]
+ cache.set(cache_key, activities, LearnerAnalyticsView.seconds_to_cache_expiration())
+
+ return activities['any']
+
+ def consecutive_weeks_of_course_activity_for_user(self, username, course_key):
+ """
+ Get the most recent count of consecutive days that a user has performed a course activity
+
+ Args:
+ username (str)
+ course_key (CourseKey)
+ """
+ cache_key = 'learner_analytics_{username}_{course_key}_engagement_timeline'\
+ .format(username=username, course_key=course_key)
+ timeline = cache.get(cache_key)
+
+ if not timeline:
+ log.info('Engagement timeline for course {course_key} was not cached - fetching from Analytics API'
+ .format(course_key=course_key))
+
+ # TODO (LEARNER-3470): @jaebradley replace this once the Analytics client has an engagement timeline method
+ url = '{base_url}/engagement_timelines/{username}?course_id={course_key}'\
+ .format(base_url=settings.ANALYTICS_API_URL,
+ username=username,
+ course_key=urllib.quote_plus(unicode(course_key)))
+ headers = {'Authorization': 'Token {token}'.format(token=settings.ANALYTICS_API_KEY)}
+ response = requests.get(url=url, headers=headers)
+ data = response.json()
+
+ if not data or 'days' not in data or not data['days']:
+ return 0
+
+ # Analytics API returns data in ascending (by date) order - we want to count starting from most recent day
+ data_ordered_by_date_descending = list(reversed(data['days']))
+
+ cache.set(cache_key, data_ordered_by_date_descending, LearnerAnalyticsView.seconds_to_cache_expiration())
+ timeline = data_ordered_by_date_descending
+
+ return LearnerAnalyticsView.calculate_week_streak(timeline)
+
+ @staticmethod
+ def calculate_week_streak(daily_activities):
+ """
+ Check number of weeks in a row that a user has performed some activity.
+
+ Regardless of when a week starts, a sufficient condition for checking if a specific week had any user activity
+ (given a list of daily activities ordered by date) is to iterate through the list of days 7 days at a time and
+ check to see if any of those days had any activity.
+
+ Args:
+ daily_activities: sorted list of dictionaries containing activities and their counts
+ """
+ week_streak = 0
+ seven_day_buckets = [daily_activities[i:i + 7] for i in range(0, len(daily_activities), 7)]
+ for bucket in seven_day_buckets:
+ if any(LearnerAnalyticsView.has_activity(day) for day in bucket):
+ week_streak += 1
+ else:
+ return week_streak
+ return week_streak
+
+ @staticmethod
+ def has_activity(daily_activity):
+ """
+ Validate that a course had some activity that day
+
+ Args:
+ daily_activity: dictionary of activities and their counts
+ """
+ return int(daily_activity['problems_attempted']) > 0 \
+ or int(daily_activity['problems_completed']) > 0 \
+ or int(daily_activity['discussion_contributions']) > 0 \
+ or int(daily_activity['videos_viewed']) > 0
+
+ @staticmethod
+ def seconds_to_cache_expiration():
+ """Calculate cache expiration seconds. Currently set to seconds until midnight UTC"""
+ next_midnight_utc = (datetime.today() + timedelta(days=1)).replace(hour=0, minute=0, second=0,
+ microsecond=0, tzinfo=pytz.utc)
+ now_utc = datetime.now(tz=pytz.utc)
+ return round((next_midnight_utc - now_utc).total_seconds())
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index b8c4d16c2e..5306bd56cb 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -99,6 +99,7 @@ git+https://github.com/edx/xblock-lti-consumer.git@v1.1.6#egg=lti_consumer-xbloc
git+https://github.com/edx/edx-proctoring.git@1.3.4#egg=edx-proctoring==1.3.4
# This is here because all of the other XBlocks are located here. However, it is published to PyPI and will be installed that way
xblock-review==1.1.2
+git+https://github.com/edx/edx-analytics-data-api-client.git@0.13.0#egg=edx-analytics-data-api-client==0.13.0
# Third Party XBlocks
diff --git a/webpack.common.config.js b/webpack.common.config.js
index 5afbd2f9df..243adef74e 100644
--- a/webpack.common.config.js
+++ b/webpack.common.config.js
@@ -25,6 +25,7 @@ module.exports = {
// LMS
SingleSupportForm: './lms/static/support/jsx/single_support_form.jsx',
AlertStatusBar: './lms/static/js/accessible_components/StatusBarAlert.jsx',
+ LearnerAnalyticsDashboard: './lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx',
// Features
CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',