Skip to content

Commit

Permalink
Add Learner Analytics dashboard.
Browse files Browse the repository at this point in the history
Add the back-end and front-end React app for Learner Analytics.
This was mostly authored by @AlasdairSwan and @dianakhuang.

LEARNER-3376
  • Loading branch information
dianakhuang authored and robrap committed Jan 16, 2018
1 parent 7e2a231 commit 7af52bf
Show file tree
Hide file tree
Showing 22 changed files with 1,402 additions and 61 deletions.
131 changes: 70 additions & 61 deletions lms/djangoapps/discussion/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2346,6 +2346,7 @@ def _make_locale_paths(settings):
'openedx.features.course_search',
'openedx.features.enterprise_support.apps.EnterpriseSupportConfig',
'openedx.features.learner_profile',
'openedx.features.learner_analytics',

'experiments',

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
116 changes: 116 additions & 0 deletions lms/static/js/learner_analytics_dashboard/CircleChart.jsx
Original file line number Diff line number Diff line change
@@ -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;
54 changes: 54 additions & 0 deletions lms/static/js/learner_analytics_dashboard/CircleChartLegend.jsx
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 7af52bf

Please sign in to comment.