Merge pull request #17204 from edx/robrap/LEARNER-3376-Learner-Analytics-Dashboard-app-squash-rebase

LEARNER-3376: Add Learner Analytics dashboard. (Squash + Rebase)
This commit is contained in:
Robert Raposa
2018-01-16 13:08:33 -05:00
committed by GitHub
22 changed files with 1402 additions and 61 deletions

View File

@@ -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

View File

@@ -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',

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -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 (
<circle cx={center} cy={center} r={size} fill={sliceBorder.strokeColor} />
);
}
}
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 (
<circle r={radius}
cx={center}
cy={center}
className="slice-1"
key={index} />
);
}
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 <path d={d}
className={`slice-${sliceIndex}`}
key={index}
stroke={strokeColor}
strokeWidth={strokeWidth} />;
});
}
render() {
const {slices, sliceBorder} = this.props;
return (
<svg viewBox={`0 0 ${size} ${size}`}>
<g transform={`rotate(-90 ${center} ${center})`}>
{this.getSlices(slices, sliceBorder)}
</g>
{this.getCenter()}
</svg>
);
}
}
CircleChart.defaultProps = {
sliceBorder: {
strokeColor: '#fff',
strokeWidth: 0
}
};
CircleChart.propTypes = {
slices: PropTypes.array.isRequired,
centerHole: PropTypes.bool,
sliceBorder: PropTypes.object
};
export default CircleChart;

View File

@@ -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 (
<li className="legend-item" key={index}>
<div className={classNames('color-swatch', swatchClass)}
aria-hidden="true"></div>
<span className="label">{label}</span>
<span className="percentage">{this.getPercentage(value)}</span>
</li>
);
});
}
getPercentage(value) {
const num = value * 100;
return `${num}%`;
}
renderList() {
return (
<ul className="legend-list">
{this.getList()}
</ul>
);
}
render() {
return (
<div className="legend">
{this.renderList()}
</div>
);
}
}
CircleChartLegend.propTypes = {
data: PropTypes.array.isRequired
}
export default CircleChartLegend;

View File

@@ -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 (
<div className="chart-wrapper">
{this.getCountChart(content_authored, authored_percent, 'You', profileImages.medium)}
{this.getCountChart(content_average, average_percent, 'Average graduate')}
</div>
);
}
getCountChart(count, percent, label, img = false) {
return (
<div className="count-chart">
<div className={classNames(
'chart-icon',
{'fa fa-graduation-cap': !img}
)}
style={{backgroundImage: !!img ? `url(${img})` : 'none'}}
aria-hidden="true"></div>
<div className="chart-label">{label}</div>
<div className="chart-display">
<div className="chart-bar"
aria-hidden="true"
style={{width: `calc((100% - 40px) * ${percent})`}}></div>
<span className="user-count">{count}</span>
</div>
</div>
);
}
render() {
const {content_authored, thread_votes} = this.props;
return (
<div className="discussions-wrapper">
<h2 className="group-heading">Discussions</h2>
<div className="comparison-charts">
<h3 className="section-heading">Posts, comments, and replies</h3>
{this.getComparisons()}
</div>
<div className="post-counts">
<div className="votes-wrapper">
<span className="fa fa-plus-square-o count-icon" aria-hidden="true"></span>
<span className="user-count">{thread_votes}</span>
<p className="label">Votes on your posts, comments, and replies</p>
</div>
</div>
</div>
);
}
}
Discussions.propTypes = {
content_authored: PropTypes.number.isRequired,
thread_votes: PropTypes.number.isRequired
}
export default Discussions;

View File

@@ -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 (
<li className="date-item" key={index}>
<div className="label">{this.getLabel(format)}</div>
<div className="data">{this.getDate(due)}</div>
</li>
);
});
}
initLabelTracker(list) {
let labels = Object.keys(list);
return labels.reduce((accumulator, key) => {
accumulator[key] = 0;
return accumulator;
}, {})
}
renderList() {
return (
<ul className="date-list">
{this.getList()}
</ul>
);
}
render() {
return (
<div className="due-dates">
{this.renderList()}
</div>
);
}
}
DueDates.propTypes = {
dates: PropTypes.array.isRequired
}
export default DueDates;

View File

@@ -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 (
<tr key={index}>
<td>{label}</td>
<td>{passing_grade}/{total_possible}</td>
<td>{total_earned <= 0 ? '-' : total_earned}/{total_possible}</td>
</tr>
);
});
return <tbody className="type-group"
key={groupIndex}>{rows}</tbody>;
}
render() {
const {assignmentTypes} = this.props;
return (
<table className="table grade-table">
<thead className="table-head">
<tr>
<th>Assessment</th>
<th>Passing</th>
<th>You</th>
</tr>
</thead>
{assignmentTypes.map((type, index) => this.getTableGroup(type, index))}
</table>
)
}
};
GradeTable.propTypes = {
assignmentTypes: PropTypes.array.isRequired,
grades: PropTypes.array.isRequired
}
export default GradeTable;

View File

@@ -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) => (
<span className="fa fa-trophy" aria-hidden="true" key={i}></span>
));
}
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 (
<div className="learner-analytics-wrapper">
<div className="main-block">
<div className="analytics-group">
<h2 className="group-heading">Grading</h2>
{gradeBreakdown &&
<h3 className="section-heading">Weight</h3>
}
{gradeBreakdown &&
<div className="grading-weight-wrapper">
<div className="chart-wrapper">
<CircleChart
slices={gradeBreakdown}
centerHole={true}
sliceBorder={{
strokeColor: '#f5f5f5',
strokeWidth: 2
}}
/>
</div>
<CircleChartLegend data={gradeBreakdown} />
</div>
}
<h3 className="section-heading">Graded Assessments</h3>
<div className="graded-assessments-wrapper">
<GradeTable assignmentTypes={assignmentTypes} grades={grades} />
</div>
</div>
<div className="analytics-group">
<Discussions {...discussion_info} profileImages={profile_images} />
</div>
</div>
<div className="analytics-group sidebar">
<h2 className="group-heading">Timing</h2>
<h3 className="section-heading">Course due dates</h3>
<DueDates dates={schedule} assignmentCounts={assignmentCounts} />
{week_streak > 0 &&
<div className="week-streak-wrapper">
<div className="streak-icon-wrapper" aria-hidden="true">{getStreakIcons(week_streak)}</div>
<h3 className="section-heading">Week streak</h3>
<p>{getStreakString(week_streak)}</p>
</div>
}
<div className="active-users-wrapper">
<span className="fa fa-user count-icon" aria-hidden="true"></span>
<span className="user-count">{weekly_active_users.toLocaleString('en', {useGrouping:true})}</span>
<p className="label">{getActiveUserString(weekly_active_users)}</p>
</div>
</div>
</div>
);
}

View File

@@ -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';

View File

@@ -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;
}
}
}
}

View File

@@ -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']:

View File

@@ -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')

View File

@@ -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">
<div class="course-view page-content-container" id="course-container">
<header class="page-header has-secondary">
## TODO: LEARNER-3854: Clean-up after Learner Analytics test.
## May not need/want breadcrumbs? Can maybe kill course_url and course_home_page_title
## from the context?
## Breadcrumb navigation
<div class="page-header-main">
<nav aria-label="${_('Learner Analytics')}" class="sr-is-focusable" tabindex="-1">
<div class="has-breadcrumbs">
<div class="breadcrumbs">
<span class="nav-item">
<a href="${course_url}">${course_home_page_title(course)}</a>
</span>
<span class="icon fa fa-angle-right" aria-hidden="true"></span>
<span class="nav-item">${_('Learner Analytics')}</span>
</div>
</div>
</nav>
</div>
</header>
<div class="page-content learner-analytics-dashboard-wrapper">
<div class="learner-analytics-dashboard">
${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,
}
)}
</div>
</div>
</div>
</%block>
<%namespace name='static' file='../static_content.html'/>

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# TODO: LEARNER-3854: Implement tests or remove file after Learner Analytics test.

View File

@@ -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',
),
]

View File

@@ -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())

View File

@@ -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

View File

@@ -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',