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,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
|
||||
|
||||
@@ -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 |
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-sock';
|
||||
@import 'features/course-upgrade-message';
|
||||
@import 'features/learner-analytics-dashboard';
|
||||
|
||||
// Responsive Design
|
||||
@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/',
|
||||
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']:
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user