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:
@@ -483,18 +483,11 @@ def _create_discussion_board_context(request, base_context, thread=None):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@require_GET
|
def create_user_profile_context(request, course_key, user_id):
|
||||||
@login_required
|
""" Generate a context dictionary for the user profile. """
|
||||||
@use_bulk_ops
|
|
||||||
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)
|
user = cc.User.from_django_user(request.user)
|
||||||
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
|
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.
|
# If user is not enrolled in the course, do not proceed.
|
||||||
django_user = User.objects.get(id=user_id)
|
django_user = User.objects.get(id=user_id)
|
||||||
if not CourseEnrollment.is_enrolled(django_user, course.id):
|
if not CourseEnrollment.is_enrolled(django_user, course.id):
|
||||||
@@ -505,10 +498,7 @@ def user_profile(request, course_key, user_id):
|
|||||||
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
|
'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)
|
group_id = get_group_id_for_comments_service(request, course_key)
|
||||||
except ValueError:
|
|
||||||
return HttpResponseServerError("Invalid group_id")
|
|
||||||
if group_id is not None:
|
if group_id is not None:
|
||||||
query_params['group_id'] = group_id
|
query_params['group_id'] = group_id
|
||||||
profiled_user = cc.User(id=user_id, course_id=course_key, group_id=group_id)
|
profiled_user = cc.User(id=user_id, course_id=course_key, group_id=group_id)
|
||||||
@@ -527,14 +517,11 @@ def user_profile(request, course_key, user_id):
|
|||||||
threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads]
|
threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads]
|
||||||
with function_trace("add_courseware_context"):
|
with function_trace("add_courseware_context"):
|
||||||
add_courseware_context(threads, course, request.user)
|
add_courseware_context(threads, course, request.user)
|
||||||
if request.is_ajax():
|
|
||||||
return utils.JsonResponse({
|
# TODO: LEARNER-3854: If we actually implement Learner Analytics code, this
|
||||||
'discussion_data': threads,
|
# code was original protected to not run in user_profile() if is_ajax().
|
||||||
'page': query_params['page'],
|
# Someone should determine if that is still necessary (i.e. was that ever
|
||||||
'num_pages': query_params['num_pages'],
|
# called as is_ajax()) and clean this up as necessary.
|
||||||
'annotated_content_info': annotated_content_info,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
user_roles = django_user.roles.filter(
|
user_roles = django_user.roles.filter(
|
||||||
course_id=course.id
|
course_id=course.id
|
||||||
).order_by("name").values_list("name", flat=True).distinct()
|
).order_by("name").values_list("name", flat=True).distinct()
|
||||||
@@ -556,10 +543,32 @@ def user_profile(request, course_key, user_id):
|
|||||||
'sort_preference': user.default_sort_key,
|
'sort_preference': user.default_sort_key,
|
||||||
'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}),
|
'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}),
|
||||||
})
|
})
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
@login_required
|
||||||
|
@use_bulk_ops
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
context = create_user_profile_context(request, course_key, user_id)
|
||||||
|
if request.is_ajax():
|
||||||
|
return utils.JsonResponse({
|
||||||
|
'discussion_data': context['threads'],
|
||||||
|
'page': context['page'],
|
||||||
|
'num_pages': context['num_pages'],
|
||||||
|
'annotated_content_info': context['annotated_content_info'],
|
||||||
|
})
|
||||||
|
else:
|
||||||
return render_to_response('discussion/discussion_profile_page.html', context)
|
return render_to_response('discussion/discussion_profile_page.html', context)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponseServerError("Invalid group_id")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -2346,6 +2346,7 @@ INSTALLED_APPS = [
|
|||||||
'openedx.features.course_search',
|
'openedx.features.course_search',
|
||||||
'openedx.features.enterprise_support.apps.EnterpriseSupportConfig',
|
'openedx.features.enterprise_support.apps.EnterpriseSupportConfig',
|
||||||
'openedx.features.learner_profile',
|
'openedx.features.learner_profile',
|
||||||
|
'openedx.features.learner_analytics',
|
||||||
|
|
||||||
'experiments',
|
'experiments',
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
lms/static/images/learner_analytics_dashboard/streak-768x517.jpg
Normal file
BIN
lms/static/images/learner_analytics_dashboard/streak-768x517.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
lms/static/images/learner_analytics_dashboard/streak-trophy.png
Normal file
BIN
lms/static/images/learner_analytics_dashboard/streak-trophy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
116
lms/static/js/learner_analytics_dashboard/CircleChart.jsx
Normal file
116
lms/static/js/learner_analytics_dashboard/CircleChart.jsx
Normal 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;
|
||||||
@@ -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;
|
||||||
80
lms/static/js/learner_analytics_dashboard/Discussions.jsx
Normal file
80
lms/static/js/learner_analytics_dashboard/Discussions.jsx
Normal 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;
|
||||||
79
lms/static/js/learner_analytics_dashboard/DueDates.jsx
Normal file
79
lms/static/js/learner_analytics_dashboard/DueDates.jsx
Normal 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;
|
||||||
72
lms/static/js/learner_analytics_dashboard/GradeTable.jsx
Normal file
72
lms/static/js/learner_analytics_dashboard/GradeTable.jsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
@import 'features/course-search';
|
@import 'features/course-search';
|
||||||
@import 'features/course-sock';
|
@import 'features/course-sock';
|
||||||
@import 'features/course-upgrade-message';
|
@import 'features/course-upgrade-message';
|
||||||
|
@import 'features/learner-analytics-dashboard';
|
||||||
|
|
||||||
// Responsive Design
|
// Responsive Design
|
||||||
@import 'header';
|
@import 'header';
|
||||||
|
|||||||
399
lms/static/sass/features/_learner-analytics-dashboard.scss
Normal file
399
lms/static/sass/features/_learner-analytics-dashboard.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -672,6 +672,14 @@ urlpatterns += [
|
|||||||
r'^u/',
|
r'^u/',
|
||||||
include('openedx.features.learner_profile.urls'),
|
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']:
|
if settings.FEATURES['ENABLE_TEAMS']:
|
||||||
|
|||||||
10
openedx/features/learner_analytics/__init__.py
Normal file
10
openedx/features/learner_analytics/__init__.py
Normal 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')
|
||||||
@@ -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'/>
|
||||||
3
openedx/features/learner_analytics/tests.py
Normal file
3
openedx/features/learner_analytics/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# TODO: LEARNER-3854: Implement tests or remove file after Learner Analytics test.
|
||||||
15
openedx/features/learner_analytics/urls.py
Normal file
15
openedx/features/learner_analytics/urls.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
297
openedx/features/learner_analytics/views.py
Normal file
297
openedx/features/learner_analytics/views.py
Normal 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())
|
||||||
@@ -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
|
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
|
# 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
|
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
|
# Third Party XBlocks
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ module.exports = {
|
|||||||
// LMS
|
// LMS
|
||||||
SingleSupportForm: './lms/static/support/jsx/single_support_form.jsx',
|
SingleSupportForm: './lms/static/support/jsx/single_support_form.jsx',
|
||||||
AlertStatusBar: './lms/static/js/accessible_components/StatusBarAlert.jsx',
|
AlertStatusBar: './lms/static/js/accessible_components/StatusBarAlert.jsx',
|
||||||
|
LearnerAnalyticsDashboard: './lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx',
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',
|
CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user