diff --git a/lms/djangoapps/discussion/views.py b/lms/djangoapps/discussion/views.py index d3fbcce41f..06658be4af 100644 --- a/lms/djangoapps/discussion/views.py +++ b/lms/djangoapps/discussion/views.py @@ -483,6 +483,69 @@ def _create_discussion_board_context(request, base_context, thread=None): return context +def create_user_profile_context(request, course_key, user_id): + """ Generate a context dictionary for the user profile. """ + user = cc.User.from_django_user(request.user) + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + + # If user is not enrolled in the course, do not proceed. + django_user = User.objects.get(id=user_id) + if not CourseEnrollment.is_enrolled(django_user, course.id): + raise Http404 + + query_params = { + 'page': request.GET.get('page', 1), + 'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities + } + + group_id = get_group_id_for_comments_service(request, course_key) + if group_id is not None: + query_params['group_id'] = group_id + profiled_user = cc.User(id=user_id, course_id=course_key, group_id=group_id) + else: + profiled_user = cc.User(id=user_id, course_id=course_key) + + threads, page, num_pages = profiled_user.active_threads(query_params) + query_params['page'] = page + query_params['num_pages'] = num_pages + + with function_trace("get_metadata_for_threads"): + user_info = cc.User.from_django_user(request.user).to_dict() + annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info) + + is_staff = has_permission(request.user, 'openclose_thread', course.id) + threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads] + with function_trace("add_courseware_context"): + add_courseware_context(threads, course, request.user) + + # TODO: LEARNER-3854: If we actually implement Learner Analytics code, this + # code was original protected to not run in user_profile() if is_ajax(). + # Someone should determine if that is still necessary (i.e. was that ever + # called as is_ajax()) and clean this up as necessary. + user_roles = django_user.roles.filter( + course_id=course.id + ).order_by("name").values_list("name", flat=True).distinct() + + with function_trace("get_cohort_info"): + course_discussion_settings = get_course_discussion_settings(course_key) + user_group_id = get_group_id_for_user(request.user, course_discussion_settings) + + context = _create_base_discussion_view_context(request, course_key) + context.update({ + 'django_user': django_user, + 'django_user_roles': user_roles, + 'profiled_user': profiled_user.to_dict(), + 'threads': threads, + 'user_group_id': user_group_id, + 'annotated_content_info': annotated_content_info, + 'page': query_params['page'], + 'num_pages': query_params['num_pages'], + 'sort_preference': user.default_sort_key, + 'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}), + }) + return context + + @require_GET @login_required @use_bulk_ops @@ -491,75 +554,21 @@ def user_profile(request, course_key, user_id): Renders a response to display the user profile page (shown after clicking on a post author's username). """ - user = cc.User.from_django_user(request.user) - course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) - try: - # If user is not enrolled in the course, do not proceed. - django_user = User.objects.get(id=user_id) - if not CourseEnrollment.is_enrolled(django_user, course.id): - raise Http404 - - query_params = { - 'page': request.GET.get('page', 1), - 'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities - } - - try: - group_id = get_group_id_for_comments_service(request, course_key) - except ValueError: - return HttpResponseServerError("Invalid group_id") - if group_id is not None: - query_params['group_id'] = group_id - profiled_user = cc.User(id=user_id, course_id=course_key, group_id=group_id) - else: - profiled_user = cc.User(id=user_id, course_id=course_key) - - threads, page, num_pages = profiled_user.active_threads(query_params) - query_params['page'] = page - query_params['num_pages'] = num_pages - - with function_trace("get_metadata_for_threads"): - user_info = cc.User.from_django_user(request.user).to_dict() - annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info) - - is_staff = has_permission(request.user, 'openclose_thread', course.id) - threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads] - with function_trace("add_courseware_context"): - add_courseware_context(threads, course, request.user) + context = create_user_profile_context(request, course_key, user_id) if request.is_ajax(): return utils.JsonResponse({ - 'discussion_data': threads, - 'page': query_params['page'], - 'num_pages': query_params['num_pages'], - 'annotated_content_info': annotated_content_info, + 'discussion_data': context['threads'], + 'page': context['page'], + 'num_pages': context['num_pages'], + 'annotated_content_info': context['annotated_content_info'], }) else: - user_roles = django_user.roles.filter( - course_id=course.id - ).order_by("name").values_list("name", flat=True).distinct() - - with function_trace("get_cohort_info"): - course_discussion_settings = get_course_discussion_settings(course_key) - user_group_id = get_group_id_for_user(request.user, course_discussion_settings) - - context = _create_base_discussion_view_context(request, course_key) - context.update({ - 'django_user': django_user, - 'django_user_roles': user_roles, - 'profiled_user': profiled_user.to_dict(), - 'threads': threads, - 'user_group_id': user_group_id, - 'annotated_content_info': annotated_content_info, - 'page': query_params['page'], - 'num_pages': query_params['num_pages'], - 'sort_preference': user.default_sort_key, - 'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}), - }) - return render_to_response('discussion/discussion_profile_page.html', context) except User.DoesNotExist: raise Http404 + except ValueError: + return HttpResponseServerError("Invalid group_id") @login_required diff --git a/lms/envs/common.py b/lms/envs/common.py index c125e9e6f2..490379a156 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2346,6 +2346,7 @@ INSTALLED_APPS = [ 'openedx.features.course_search', 'openedx.features.enterprise_support.apps.EnterpriseSupportConfig', 'openedx.features.learner_profile', + 'openedx.features.learner_analytics', 'experiments', diff --git a/lms/static/images/learner_analytics_dashboard/analytics-grading.png b/lms/static/images/learner_analytics_dashboard/analytics-grading.png new file mode 100644 index 0000000000..bc91300b44 Binary files /dev/null and b/lms/static/images/learner_analytics_dashboard/analytics-grading.png differ diff --git a/lms/static/images/learner_analytics_dashboard/streak-1140x768.jpg b/lms/static/images/learner_analytics_dashboard/streak-1140x768.jpg new file mode 100644 index 0000000000..0568984f70 Binary files /dev/null and b/lms/static/images/learner_analytics_dashboard/streak-1140x768.jpg differ diff --git a/lms/static/images/learner_analytics_dashboard/streak-768x517.jpg b/lms/static/images/learner_analytics_dashboard/streak-768x517.jpg new file mode 100644 index 0000000000..37f88736ba Binary files /dev/null and b/lms/static/images/learner_analytics_dashboard/streak-768x517.jpg differ diff --git a/lms/static/images/learner_analytics_dashboard/streak-trophy.png b/lms/static/images/learner_analytics_dashboard/streak-trophy.png new file mode 100644 index 0000000000..8bc6fa887b Binary files /dev/null and b/lms/static/images/learner_analytics_dashboard/streak-trophy.png differ diff --git a/lms/static/js/learner_analytics_dashboard/CircleChart.jsx b/lms/static/js/learner_analytics_dashboard/CircleChart.jsx new file mode 100644 index 0000000000..fe59464912 --- /dev/null +++ b/lms/static/js/learner_analytics_dashboard/CircleChart.jsx @@ -0,0 +1,116 @@ +import React from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; + +const size = 100; +const radCircumference = Math.PI * 2; +const center = size / 2; +const radius = center - 1; // padding to prevent clipping + +// Based on https://github.com/brigade/react-simple-pie-chart +class CircleChart extends React.Component { + constructor(props) { + super(props); + this.getCenter = this.getCenter.bind(this); + this.getSlices = this.getSlices.bind(this); + } + + getCenter() { + const {centerHole, sliceBorder} = this.props; + if (centerHole) { + const size = center / 2; + return ( + + ); + } + } + + getSlices(slices, sliceBorder) { + const total = slices.reduce((totalValue, { value }) => totalValue + value, 0); + const {strokeColor, strokeWidth} = sliceBorder; + + let radSegment = 0; + let lastX = radius; + let lastY = 0; + + // Reverse a copy of the array so order start at 12 o'clock + return slices.slice(0).reverse().map(({ value, sliceIndex }, index) => { + // Should we just draw a circle? + if (value === total) { + return ( + + ); + } + + if (value === 0) { + return; + } + + const valuePercentage = value / total; + + // Should the arc go the long way round? + const longArc = (valuePercentage <= 0.5) ? 0 : 1; + + radSegment += valuePercentage * radCircumference; + const nextX = Math.cos(radSegment) * radius; + const nextY = Math.sin(radSegment) * radius; + + /** + * d is a string that describes the path of the slice. + * The weirdly placed minus signs [eg, (-(lastY))] are due to the fact + * that our calculations are for a graph with positive Y values going up, + * but on the screen positive Y values go down. + */ + const d = [ + `M ${center},${center}`, + `l ${lastX},${-lastY}`, + `a${radius},${radius}`, + '0', + `${longArc},0`, + `${nextX - lastX},${-(nextY - lastY)}`, + 'z', + ].join(' '); + + lastX = nextX; + lastY = nextY; + + return ; + }); + } + + render() { + const {slices, sliceBorder} = this.props; + + return ( + + + {this.getSlices(slices, sliceBorder)} + + {this.getCenter()} + + ); + } +} + +CircleChart.defaultProps = { + sliceBorder: { + strokeColor: '#fff', + strokeWidth: 0 + } +}; + +CircleChart.propTypes = { + slices: PropTypes.array.isRequired, + centerHole: PropTypes.bool, + sliceBorder: PropTypes.object +}; + +export default CircleChart; diff --git a/lms/static/js/learner_analytics_dashboard/CircleChartLegend.jsx b/lms/static/js/learner_analytics_dashboard/CircleChartLegend.jsx new file mode 100644 index 0000000000..73a2e58ae6 --- /dev/null +++ b/lms/static/js/learner_analytics_dashboard/CircleChartLegend.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; + +class CircleChartLegend extends React.Component { + constructor(props) { + super(props); + } + + getList() { + const {data} = this.props; + + return data.map(({ value, label, sliceIndex }, index) => { + const swatchClass = `swatch-${sliceIndex}`; + return ( +
  • + + {label} + {this.getPercentage(value)} +
  • + ); + }); + } + + getPercentage(value) { + const num = value * 100; + + return `${num}%`; + } + + renderList() { + return ( +
      + {this.getList()} +
    + ); + } + + render() { + return ( +
    + {this.renderList()} +
    + ); + } +} + + +CircleChartLegend.propTypes = { + data: PropTypes.array.isRequired +} + +export default CircleChartLegend; diff --git a/lms/static/js/learner_analytics_dashboard/Discussions.jsx b/lms/static/js/learner_analytics_dashboard/Discussions.jsx new file mode 100644 index 0000000000..a2ae32fb0c --- /dev/null +++ b/lms/static/js/learner_analytics_dashboard/Discussions.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; + +class Discussions extends React.Component { + constructor(props) { + super(props); + } + + getComparisons() { + const {content_authored, profileImages} = this.props; + const content_average = 7; + let average_percent = 1; + let authored_percent = 0; + + if (content_average > content_authored) { + average_percent = 1; + authored_percent = content_authored / content_average; + } else { + authored_percent = 1; + average_percent = content_average / content_authored; + } + + return ( +
    + {this.getCountChart(content_authored, authored_percent, 'You', profileImages.medium)} + {this.getCountChart(content_average, average_percent, 'Average graduate')} +
    + ); + } + + getCountChart(count, percent, label, img = false) { + return ( +
    + +
    {label}
    +
    + + {count} +
    +
    + ); + } + + render() { + const {content_authored, thread_votes} = this.props; + + return ( +
    +

    Discussions

    +
    +

    Posts, comments, and replies

    + {this.getComparisons()} +
    +
    +
    + + {thread_votes} +

    Votes on your posts, comments, and replies

    +
    +
    +
    + ); + } +} + + +Discussions.propTypes = { + content_authored: PropTypes.number.isRequired, + thread_votes: PropTypes.number.isRequired +} + +export default Discussions; diff --git a/lms/static/js/learner_analytics_dashboard/DueDates.jsx b/lms/static/js/learner_analytics_dashboard/DueDates.jsx new file mode 100644 index 0000000000..2700a08486 --- /dev/null +++ b/lms/static/js/learner_analytics_dashboard/DueDates.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; + +const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +class DueDates extends React.Component { + constructor(props) { + super(props); + } + + getDate(str) { + const date = new Date(str); + const day = days[date.getDay()]; + const month = months[date.getMonth()]; + const number = date.getDate(); + const year = date.getFullYear(); + + return `${day} ${month} ${number}, ${year}`; + } + + getLabel(type) { + const {assignmentCounts} = this.props; + if (assignmentCounts[type] < 2 ) { + return type; + } else { + this.renderLabels[type] += 1; + return type + ' ' + this.renderLabels[type]; + } + } + + getList() { + const {dates, assignmentCounts} = this.props; + this.renderLabels = this.initLabelTracker(assignmentCounts); + + return dates.sort((a, b) => new Date(a.due) > new Date(b.due)) + .map(({ format, due }, index) => { + return ( +
  • +
    {this.getLabel(format)}
    +
    {this.getDate(due)}
    +
  • + ); + }); + } + + initLabelTracker(list) { + let labels = Object.keys(list); + + return labels.reduce((accumulator, key) => { + accumulator[key] = 0; + return accumulator; + }, {}) + } + + renderList() { + return ( +
      + {this.getList()} +
    + ); + } + + render() { + return ( +
    + {this.renderList()} +
    + ); + } +} + + +DueDates.propTypes = { + dates: PropTypes.array.isRequired +} + +export default DueDates; diff --git a/lms/static/js/learner_analytics_dashboard/GradeTable.jsx b/lms/static/js/learner_analytics_dashboard/GradeTable.jsx new file mode 100644 index 0000000000..b0bbca0fb1 --- /dev/null +++ b/lms/static/js/learner_analytics_dashboard/GradeTable.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +const exGrades = [ + { + "assignment_type":"Exam", + "total_possible":6.0, + "total_earned":3.0 + }, + { + "assignment_type":"Homework", + "total_possible":5.0, + }, + { + "assignment_type":"Homework", + "total_possible":11.0, + "total_earned":0.0 + } +]; + +class GradeTable extends React.Component { + constructor(props) { + super(props); + } + + getTableGroup(type, groupIndex) { + const {grades} = this.props; + const groupData = grades.filter(value => { + if (value['assignment_type'] === type) { + return value; + } + }); + const multipleAssessments = groupData.length > 1; + + const rows = groupData.map(({assignment_type, total_possible, total_earned, passing_grade}, index) => { + const label = multipleAssessments ? `${assignment_type} ${index + 1}` : assignment_type; + return ( + + {label} + {passing_grade}/{total_possible} + {total_earned <= 0 ? '-' : total_earned}/{total_possible} + + ); + }); + + return {rows}; + } + + render() { + const {assignmentTypes} = this.props; + return ( + + + + + + + + + {assignmentTypes.map((type, index) => this.getTableGroup(type, index))} +
    AssessmentPassingYou
    + ) + } +}; + +GradeTable.propTypes = { + assignmentTypes: PropTypes.array.isRequired, + grades: PropTypes.array.isRequired +} + +export default GradeTable; diff --git a/lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx b/lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx new file mode 100644 index 0000000000..acba4da9b8 --- /dev/null +++ b/lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx @@ -0,0 +1,127 @@ +/* global gettext */ + +import PropTypes from 'prop-types'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import CircleChart from './CircleChart'; +import CircleChartLegend from './CircleChartLegend'; +import GradeTable from './GradeTable'; +import DueDates from './DueDates'; +import Discussions from './Discussions'; + +function arrayToObject(array) { + return array.reduce((accumulator, obj) => { + const key = Object.keys(obj)[0]; + accumulator[key] = obj[key]; + return accumulator; + }, {}) +} + +function countByType(type, assignments) { + let count = 0; + assignments.map(({format}) => { + if (format === type) { + count += 1; + } + }) + return count; +} + +function getActiveUserString(count) { + const users = (count === 1) ? 'User' : 'Users'; + return `${users} active in this course right now`; +} + +function getAssignmentCounts(types, assignments) { + const countsArray = types.map((type) => { + return { + [type]: countByType(type, assignments) + } + }); + + return arrayToObject(countsArray); +} + +function getStreakIcons(count) { + return Array.apply(null, { length: count }).map((e, i) => ( + + )); +} + +function getStreakString(count) { + const unit = (count ===1) ? 'week' : 'weeks'; + return `Logged in ${count} ${unit} in a row`; +} + +export function LearnerAnalyticsDashboard(props) { + const {grading_policy, grades, schedule, week_streak, weekly_active_users, discussion_info, profile_images} = props; + // temp. for local dev + // const week_streak = 3; + // const weekly_active_users = 83400; + const gradeBreakdown = grading_policy.GRADER.map(({type, weight}, index) => { + return { + value: weight, + label: type, + sliceIndex: index + 1 + } + }); + + // Get a list of assignment types minus duplicates + const assignments = gradeBreakdown.map(value => value['label']); + const assignmentTypes = [...new Set(assignments)]; + const assignmentCounts = getAssignmentCounts(assignmentTypes, schedule); + + return ( +
    +
    +
    +

    Grading

    + {gradeBreakdown && +

    Weight

    + } + {gradeBreakdown && +
    +
    + +
    + +
    + } + +

    Graded Assessments

    +
    + +
    +
    +
    + +
    +
    +
    +

    Timing

    +

    Course due dates

    + + {week_streak > 0 && +
    + +

    Week streak

    +

    {getStreakString(week_streak)}

    +
    + } +
    + + {weekly_active_users.toLocaleString('en', {useGrouping:true})} +

    {getActiveUserString(weekly_active_users)}

    +
    +
    +
    + ); +} + diff --git a/lms/static/sass/_build-lms-v2.scss b/lms/static/sass/_build-lms-v2.scss index 66d40d9b39..180b029bca 100644 --- a/lms/static/sass/_build-lms-v2.scss +++ b/lms/static/sass/_build-lms-v2.scss @@ -32,6 +32,7 @@ @import 'features/course-search'; @import 'features/course-sock'; @import 'features/course-upgrade-message'; +@import 'features/learner-analytics-dashboard'; // Responsive Design @import 'header'; diff --git a/lms/static/sass/features/_learner-analytics-dashboard.scss b/lms/static/sass/features/_learner-analytics-dashboard.scss new file mode 100644 index 0000000000..267459bc20 --- /dev/null +++ b/lms/static/sass/features/_learner-analytics-dashboard.scss @@ -0,0 +1,399 @@ +$mint-green: #92c9d3; +$slices: #386f77, #1abc9c, $mint-green, #397d1c, #a39d3d, #0a5aaa; +$border-color: #4a4a4a; +$table-border: rgba(155, 155, 155, 0.2); +$font-color: #636c72; +$heading-color: #292b2c; +$grouping-background: #f5f5f5; +$grouping-border: #d8d8d8; +$trophy-gold: #f39c12; + +.content-wrapper { + .page-content.learner-analytics-dashboard-wrapper { + flex-direction: column; + background: #fff; + } +} + +.learner-analytics-dashboard { + .sidebar { + position: relative; + + &::before { + content: ''; + width: 100%; + height: 100px; + position: absolute; + bottom: 0; + left: 0; + background: { + image: url('#{$static-path}/images/learner_analytics_dashboard/streak-768x517.jpg'); + repeat: no-repeat; + position: center; + } + } + &::after { + content: ''; + width: 151px; + height: 192px; + position: absolute; + bottom: 0; + right: 0; + background: { + image: url('#{$static-path}/images/learner_analytics_dashboard/streak-trophy.png'); + repeat: no-repeat; + position: bottom right; + } + } + } + + .analytics-group { + background: $grouping-background; + border: 1px solid $grouping-border; + padding: 20px 20px 120px; + margin: 20px 0 40px; + + &:last-of-type { + margin-bottom: 20px; + } + + .group-heading { + color: $heading-color; + font-size: 1.5em; + } + + .section-heading { + color: $heading-color; + font: { + size: 1em; + weight: bold; + } + } + } + + .grading-weight-wrapper { + display: flex; + flex-flow: row wrap; + border-bottom: 1px solid $border-color; + .chart-wrapper { + width: 90px; + height: 90px; + margin-bottom: 20px; + + .slice-1 { + fill: nth($slices, 1); + } + .slice-2 { + fill: nth($slices, 2); + } + .slice-3 { + fill: nth($slices, 3); + } + .slice-4 { + fill: nth($slices, 4); + } + .slice-5 { + fill: nth($slices, 5); + } + .slice-6 { + fill: nth($slices, 6); + } + } + } + + .legend { + .legend-list { + list-style: none; + padding: 0; + margin: 0 0 20px 20px; + display: flex; + flex-direction: column; + align-items: stretch; + } + + .legend-item { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 10px; + font-size: 0.875em; + + .color-swatch { + width: 20px; + height: 20px; + border-radius: 3px; + margin-right: 10px; + + &.swatch-1 { + background-color: nth($slices, 1); + } + &.swatch-2 { + background-color: nth($slices, 2); + } + &.swatch-3 { + background-color: nth($slices, 3); + } + &.swatch-4 { + background-color: nth($slices, 4); + } + &.swatch-5 { + background-color: nth($slices, 5); + } + &.swatch-6 { + background-color: nth($slices, 6); + } + } + + .label, + .percentage { + color: $font-color; + } + + .label { + margin-right: 30px; + } + + .percentage { + margin-left: auto; + } + } + } + + .graded-assessments-wrapper { + .grade-table { + width: 100%; + margin-bottom: 30px; + font-size: 0.875em; + + th { + font-weight: bold; + + &:nth-of-type(3) { + width: 80px; + } + } + + th, + td { + border: none; + color: $font-color; + + &:nth-of-type(2), + &:nth-of-type(3) { + text-align: right; + } + + &:nth-of-type(3) { + background: rgba(255, 193, 7, 0.15); + color: #000; + font-weight: bold; + } + } + } + + .footnote { + font-size: 0.875em; + color: $font-color; + } + + .table-head { + border-bottom: 2px solid $table-border; + } + + .type-group { + border-bottom: 1px solid $table-border; + + &:last-of-type { + border-bottom-width: 2px; + } + } + } + + .due-dates { + border-bottom: 1px solid $table-border; + padding-bottom: 30px; + margin-bottom: 30px; + + .date-list { + list-style: none; + padding: 0; + margin: 0; + + .date-item { + display: flex; + justify-content: space-between; + color: $font-color; + } + } + } + + .week-streak-wrapper { + color: $font-color; + + .streak-icon-wrapper { + margin-bottom: 10px; + } + + .fa-trophy { + color: $trophy-gold; + font-size: 21px; + margin-right: 5px; + } + + .section-heading { + margin-bottom: 0; + } + } + + .active-users-wrapper { + .fa-user { + border: 2px solid $mint-green; + border-radius: 50%; + width: 28px; + height: 28px; + text-align: center; + position: relative; + + &::after { + content: ''; + height: 2px; + width: 12px; + position: absolute; + background-color: $mint-green; + left: 6px; + bottom: 0; + } + } + + + .label { + color: $font-color; + } + } + + .discussions-wrapper { + display: flex; + flex-flow: column wrap; + .group-heading { + width: 100%; + } + + .post-counts { + border-top: 1px solid $table-border; + } + } + + .count-icon { + color: $mint-green; + font-size: 24px; + } + + .user-count { + font-size: 1.5em; + margin-left: 10px; + } + + .count-chart { + position: relative; + min-height: 50px; + margin-bottom: 20px; + padding-left: 70px; + + .chart-bar { + height: 10px; + border-radius: 2px; + background: $mint-green; + } + + .chart-icon { + width: 50px; + height: 50px; + background: $mint-green; + border-radius: 50%; + text-align: center; + font-size: 24px; + padding-top: 13px; + color: white; + position: absolute; + left: 0; + top: 0; + } + + &:first-of-type { + .chart-bar, + .chart-icon { + background: $trophy-gold; + } + } + + .chart-display { + display: flex; + flex-flow: row wrap; + align-items: baseline; + + .user-count { + margin-top: -10px; + } + } + } + + @include media-breakpoint-up(md) { + .sidebar { + &::before { + background-image: url('#{$static-path}/images/learner_analytics_dashboard/streak-1140x768.jpg'); + } + } + + .analytics-group { + &:first-of-type { + background: { + image: url('#{$static-path}/images/learner_analytics_dashboard/analytics-grading.png'); + repeat: no-repeat; + position: top right; + } + } + } + } + + @include media-breakpoint-up(lg) { + .discussions-wrapper { + flex-direction: row; + + .comparison-charts { + border-right: 1px solid $table-border; + width: 50%; + } + + .post-counts { + border: none; + width: 50%; + padding-left: 20px; + } + } + } + + @include media-breakpoint-up(xl) { + .sidebar { + &::before { + background-image: url('#{$static-path}/images/learner_analytics_dashboard/streak-768x517.jpg'); + } + } + + .learner-analytics-wrapper { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: flex-start; + + .main-block { + width: calc(100% - 390px); + } + + .sidebar { + width: 360px; + margin-left: 30px; + } + } + } +} + diff --git a/lms/urls.py b/lms/urls.py index 05a3f27b9e..159f23126f 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -672,6 +672,14 @@ urlpatterns += [ r'^u/', include('openedx.features.learner_profile.urls'), ), + + # Learner analytics dashboard + url( + r'^courses/{}/learner_analytics/'.format( + settings.COURSE_ID_PATTERN, + ), + include('openedx.features.learner_analytics.urls'), + ), ] if settings.FEATURES['ENABLE_TEAMS']: diff --git a/openedx/features/learner_analytics/__init__.py b/openedx/features/learner_analytics/__init__.py new file mode 100644 index 0000000000..51324589cf --- /dev/null +++ b/openedx/features/learner_analytics/__init__.py @@ -0,0 +1,10 @@ +""" +Learner analytics helpers and settings +""" +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace + + +# Namespace for learner analytics waffle flags. +WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='learner_analytics') + +ENABLE_DASHBOARD_TAB = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'enable_dashboard_tab') diff --git a/openedx/features/learner_analytics/templates/learner_analytics/dashboard.html b/openedx/features/learner_analytics/templates/learner_analytics/dashboard.html new file mode 100644 index 0000000000..8b1b983c30 --- /dev/null +++ b/openedx/features/learner_analytics/templates/learner_analytics/dashboard.html @@ -0,0 +1,68 @@ +## mako +<%page expression_filter="h"/> + +<%! main_css = "style-main-v2" %> + +<%inherit file="../main.html" /> +<%namespace name='static' file='../static_content.html'/> +<%def name="online_help_token()"><% return "courseware" %> +<%def name="course_name()"> +<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %> + + +<%! +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 name="pagetitle">${course_name()} + +<%include file="../courseware/course_navigation.html" args="active_page='learner_analytics'" /> + +<%block name="content"> +
    + +
    +
    + ${static.renderReact( + component="LearnerAnalyticsDashboard", + id="react-learner-analytics-dashboard", + props={ + 'schedule': assignment_schedule, + 'grading_policy': grading_policy, + 'grades': assignment_grades, + 'discussion_info': discussion_info, + 'weekly_active_users': weekly_active_users, + 'week_streak': week_streak, + 'profile_images': profile_image_urls, + } + )} +
    +
    +
    + +<%namespace name='static' file='../static_content.html'/> diff --git a/openedx/features/learner_analytics/tests.py b/openedx/features/learner_analytics/tests.py new file mode 100644 index 0000000000..3f734ee41e --- /dev/null +++ b/openedx/features/learner_analytics/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# TODO: LEARNER-3854: Implement tests or remove file after Learner Analytics test. diff --git a/openedx/features/learner_analytics/urls.py b/openedx/features/learner_analytics/urls.py new file mode 100644 index 0000000000..becb96d3c7 --- /dev/null +++ b/openedx/features/learner_analytics/urls.py @@ -0,0 +1,15 @@ +""" +Url setup for learner analytics +""" +from django.conf.urls import url + +from views import LearnerAnalyticsView + + +urlpatterns = [ + url( + r'^$', + LearnerAnalyticsView.as_view(), + name='openedx.learner_analytics.dashboard', + ), +] diff --git a/openedx/features/learner_analytics/views.py b/openedx/features/learner_analytics/views.py new file mode 100644 index 0000000000..3bb82169e3 --- /dev/null +++ b/openedx/features/learner_analytics/views.py @@ -0,0 +1,297 @@ +""" +Learner analytics dashboard views +""" +import logging +import math +import urllib +from datetime import datetime, timedelta + +import pytz +import requests +from analyticsclient.client import Client +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.core.cache import cache +from django.core.urlresolvers import reverse +from django.http import Http404 +from django.shortcuts import render_to_response +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_control +from django.views.generic import View +from opaque_keys.edx.keys import CourseKey +from student.models import CourseEnrollment +from util.views import ensure_valid_course_key +from xmodule.modulestore.django import modulestore + +from lms.djangoapps.course_api.blocks.api import get_blocks +from lms.djangoapps.courseware.courses import get_course_with_access +from lms.djangoapps.discussion.views import create_user_profile_context +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory +from openedx.features.course_experience import default_course_url_name +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user + +from . import ENABLE_DASHBOARD_TAB + +log = logging.getLogger(__name__) + + +class LearnerAnalyticsView(View): + """ + Displays the Learner Analytics Dashboard. + """ + def __init__(self): + View.__init__(self) + self.analytics_client = Client(base_url=settings.ANALYTICS_API_URL, auth_token=settings.ANALYTICS_API_KEY) + + @method_decorator(login_required) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_valid_course_key) + def get(self, request, course_id): + """ + Displays the user's Learner Analytics for the specified course. + + Arguments: + request: HTTP request + course_id (unicode): course id + """ + course_key = CourseKey.from_string(course_id) + if not ENABLE_DASHBOARD_TAB.is_enabled(course_key): + raise Http404 + + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + course_url_name = default_course_url_name(course.id) + course_url = reverse(course_url_name, kwargs={'course_id': unicode(course.id)}) + + grading_policy = course.grading_policy + + (grade_data, answered_percent) = self.get_grade_data(request.user, course_key, grading_policy['GRADE_CUTOFFS']) + schedule_data = self.get_assignments_with_due_date(request, course_key) + (grade_data, schedule_data) = self.sort_grade_and_schedule_data(grade_data, schedule_data) + + context = { + 'course': course, + 'course_url': course_url, + 'disable_courseware_js': True, + 'uses_pattern_library': True, + 'is_self_paced': course.self_paced, + 'is_verified': CourseEnrollment.is_enrolled_as_verified(request.user, course_key), + 'grading_policy': grading_policy, + 'assignment_grades': grade_data, + 'answered_percent': answered_percent, + 'assignment_schedule': schedule_data, + 'profile_image_urls': get_profile_image_urls_for_user(request.user, request), + 'discussion_info': self.get_discussion_data(request, course_key), + 'weekly_active_users': self.get_weekly_course_activity_count(course_key), + 'week_streak': self.consecutive_weeks_of_course_activity_for_user( + request.user.username, course_key + ) + } + + return render_to_response('learner_analytics/dashboard.html', context) + + def get_grade_data(self, user, course_key, grade_cutoffs): + """ + Collects and formats the grades data for a particular user and course. + + Args: + user (User) + course_key (CourseKey) + grade_cutoffs: # TODO: LEARNER-3854: Complete docstring if implementing Learner Analytics. + """ + course_grade = CourseGradeFactory().read(user, course_key=course_key) + grades = [] + total_earned = 0 + total_possible = 0 + answered_percent = None + for (location, subsection_grade) in course_grade.subsection_grades.iteritems(): + if subsection_grade.format is not None: + possible = subsection_grade.graded_total.possible + earned = subsection_grade.graded_total.earned + passing_grade = math.ceil(possible * grade_cutoffs['Pass']) + grades.append({ + 'assignment_type': subsection_grade.format, + 'total_earned': earned, + 'total_possible': possible, + 'passing_grade': passing_grade, + 'location': unicode(location), + 'assigment_url': reverse('jump_to_id', kwargs={ + 'course_id': unicode(course_key), + 'module_id': unicode(location), + }) + }) + if earned > 0: + total_earned += earned + total_possible += possible + + if total_possible > 0: + answered_percent = float(total_earned) / total_possible + return (grades, answered_percent) + + def sort_grade_and_schedule_data(self, grade_data, schedule_data): + """ + Sort the assignments in grade_data and schedule_data to be in the same order. + """ + schedule_dict = {assignment['location']: assignment for assignment in schedule_data} + + sorted_schedule_data = [] + sorted_grade_data = [] + for grade in grade_data: + assignment = schedule_dict.get(grade['location']) + if assignment: + sorted_grade_data.append(grade) + sorted_schedule_data.append(assignment) + + return sorted_grade_data, sorted_schedule_data + + def get_discussion_data(self, request, course_key): + """ + Collects and formats the discussion data from a particular user and course. + + Args: + request (HttpRequest) + course_key (CourseKey) + """ + context = create_user_profile_context(request, course_key, request.user.id) + threads = context['threads'] + profiled_user = context['profiled_user'] + + # TODO: LEARNER-3854: If implementing Learner Analytics, rename to content_authored_count. + content_authored = profiled_user['threads_count'] + profiled_user['comments_count'] + thread_votes = 0 + for thread in threads: + if thread['user_id'] == profiled_user['external_id']: + thread_votes += thread['votes']['count'] + discussion_data = { + 'content_authored': content_authored, + 'thread_votes': thread_votes, + } + return discussion_data + + def get_assignments_with_due_date(self, request, course_key): + """ + Returns a list of assignment (graded) blocks with due dates, including + due date and location. + + Args: + request (HttpRequest) + course_key (CourseKey) + """ + course_usage_key = modulestore().make_course_usage_key(course_key) + all_blocks = get_blocks( + request, + course_usage_key, + user=request.user, + nav_depth=3, + requested_fields=['display_name', 'due', 'graded', 'format'], + block_types_filter=['sequential'] + ) + assignment_blocks = [] + for (location, block) in all_blocks['blocks'].iteritems(): + if block.get('graded', False) and block.get('due') is not None: + assignment_blocks.append(block) + block['due'] = block['due'].isoformat() + block['location'] = unicode(location) + + return assignment_blocks + + def get_weekly_course_activity_count(self, course_key): + """ + Get the count of any course activity (total for all users) from previous 7 days. + + Args: + course_key (CourseKey) + """ + cache_key = 'learner_analytics_{course_key}_weekly_activities'.format(course_key=course_key) + activities = cache.get(cache_key) + + if not activities: + log.info('Weekly course activities for course {course_key} was not cached - fetching from Analytics API' + .format(course_key=course_key)) + weekly_course_activities = self.analytics_client.courses(course_key).activity() + + if not weekly_course_activities or 'any' not in weekly_course_activities[0]: + return 0 + + # weekly course activities should only have one item + activities = weekly_course_activities[0] + cache.set(cache_key, activities, LearnerAnalyticsView.seconds_to_cache_expiration()) + + return activities['any'] + + def consecutive_weeks_of_course_activity_for_user(self, username, course_key): + """ + Get the most recent count of consecutive days that a user has performed a course activity + + Args: + username (str) + course_key (CourseKey) + """ + cache_key = 'learner_analytics_{username}_{course_key}_engagement_timeline'\ + .format(username=username, course_key=course_key) + timeline = cache.get(cache_key) + + if not timeline: + log.info('Engagement timeline for course {course_key} was not cached - fetching from Analytics API' + .format(course_key=course_key)) + + # TODO (LEARNER-3470): @jaebradley replace this once the Analytics client has an engagement timeline method + url = '{base_url}/engagement_timelines/{username}?course_id={course_key}'\ + .format(base_url=settings.ANALYTICS_API_URL, + username=username, + course_key=urllib.quote_plus(unicode(course_key))) + headers = {'Authorization': 'Token {token}'.format(token=settings.ANALYTICS_API_KEY)} + response = requests.get(url=url, headers=headers) + data = response.json() + + if not data or 'days' not in data or not data['days']: + return 0 + + # Analytics API returns data in ascending (by date) order - we want to count starting from most recent day + data_ordered_by_date_descending = list(reversed(data['days'])) + + cache.set(cache_key, data_ordered_by_date_descending, LearnerAnalyticsView.seconds_to_cache_expiration()) + timeline = data_ordered_by_date_descending + + return LearnerAnalyticsView.calculate_week_streak(timeline) + + @staticmethod + def calculate_week_streak(daily_activities): + """ + Check number of weeks in a row that a user has performed some activity. + + Regardless of when a week starts, a sufficient condition for checking if a specific week had any user activity + (given a list of daily activities ordered by date) is to iterate through the list of days 7 days at a time and + check to see if any of those days had any activity. + + Args: + daily_activities: sorted list of dictionaries containing activities and their counts + """ + week_streak = 0 + seven_day_buckets = [daily_activities[i:i + 7] for i in range(0, len(daily_activities), 7)] + for bucket in seven_day_buckets: + if any(LearnerAnalyticsView.has_activity(day) for day in bucket): + week_streak += 1 + else: + return week_streak + return week_streak + + @staticmethod + def has_activity(daily_activity): + """ + Validate that a course had some activity that day + + Args: + daily_activity: dictionary of activities and their counts + """ + return int(daily_activity['problems_attempted']) > 0 \ + or int(daily_activity['problems_completed']) > 0 \ + or int(daily_activity['discussion_contributions']) > 0 \ + or int(daily_activity['videos_viewed']) > 0 + + @staticmethod + def seconds_to_cache_expiration(): + """Calculate cache expiration seconds. Currently set to seconds until midnight UTC""" + next_midnight_utc = (datetime.today() + timedelta(days=1)).replace(hour=0, minute=0, second=0, + microsecond=0, tzinfo=pytz.utc) + now_utc = datetime.now(tz=pytz.utc) + return round((next_midnight_utc - now_utc).total_seconds()) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index b8c4d16c2e..5306bd56cb 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -99,6 +99,7 @@ git+https://github.com/edx/xblock-lti-consumer.git@v1.1.6#egg=lti_consumer-xbloc git+https://github.com/edx/edx-proctoring.git@1.3.4#egg=edx-proctoring==1.3.4 # This is here because all of the other XBlocks are located here. However, it is published to PyPI and will be installed that way xblock-review==1.1.2 +git+https://github.com/edx/edx-analytics-data-api-client.git@0.13.0#egg=edx-analytics-data-api-client==0.13.0 # Third Party XBlocks diff --git a/webpack.common.config.js b/webpack.common.config.js index 5afbd2f9df..243adef74e 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -25,6 +25,7 @@ module.exports = { // LMS SingleSupportForm: './lms/static/support/jsx/single_support_form.jsx', AlertStatusBar: './lms/static/js/accessible_components/StatusBarAlert.jsx', + LearnerAnalyticsDashboard: './lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx', // Features CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',