Files
edx-platform/openedx/features/learner_analytics/views.py
2019-02-15 10:15:51 -05:00

348 lines
14 KiB
Python

"""
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.urls 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 course_modes.models import get_cosmetic_verified_display_price
from lms.djangoapps.course_api.blocks.api import get_blocks
from lms.djangoapps.commerce.utils import EcommerceService
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)})
is_verified = CourseEnrollment.is_enrolled_as_verified(request.user, course_key)
has_access = is_verified or request.user.is_staff
enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
upgrade_price = None
upgrade_url = None
if enrollment and enrollment.upgrade_deadline:
upgrade_url = EcommerceService().upgrade_url(request.user, course_key)
upgrade_price = get_cosmetic_verified_display_price(course)
context = {
'upgrade_price': upgrade_price,
'upgrade_link': upgrade_url,
'course': course,
'course_url': course_url,
'disable_courseware_js': True,
'uses_pattern_library': True,
'is_self_paced': course.self_paced,
'is_verified': is_verified,
'has_access': has_access,
}
if (has_access):
grading_policy = course.grading_policy
(raw_grade_data, answered_percent, percent_grade) = self.get_grade_data(request.user, course_key, grading_policy['GRADE_CUTOFFS'])
raw_schedule_data = self.get_assignments_with_due_date(request, course_key)
grade_data, schedule_data = self.sort_grade_and_schedule_data(raw_grade_data, raw_schedule_data)
# TODO: LEARNER-3854: Fix hacked defaults with real error handling if implementing Learner Analytics.
try:
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
)
except Exception as e:
logging.exception(e)
weekly_active_users = 134
week_streak = 1
context.update({
'grading_policy': grading_policy,
'assignment_grades': grade_data,
'answered_percent': answered_percent,
'assignment_schedule': schedule_data,
'assignment_schedule_raw': raw_schedule_data,
'profile_image_urls': get_profile_image_urls_for_user(request.user, request),
'discussion_info': self.get_discussion_data(request, course_key),
'passing_grade': math.ceil(100 * course.lowest_passing_grade),
'percent_grade': math.ceil(100 * percent_grade),
'weekly_active_users': weekly_active_users,
'week_streak': week_streak,
})
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 seems to be unused and it does not take into account assignment type weightings
answered_percent = None
chapter_grades = course_grade.chapter_grades.values()
for chapter in chapter_grades:
# Note: this code exists on the progress page. We should be able to remove it going forward.
if not chapter['display_name'] == "hidden":
for subsection_grade in chapter['sections']:
log.info(subsection_grade.display_name)
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,
'display_name': subsection_grade.display_name,
'location': unicode(subsection_grade.location),
'assigment_url': reverse('jump_to_id', kwargs={
'course_id': unicode(course_key),
'module_id': unicode(subsection_grade.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, course_grade.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)
"""
try:
context = create_user_profile_context(request, course_key, request.user.id)
except Exception as e:
# TODO: LEARNER-3854: Clean-up error handling if continuing support.
return {
'content_authored': 0,
'thread_votes': 0,
}
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):
assignment_blocks.append(block)
block['due'] = block['due'].isoformat() if block.get('due') is not None else None
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(u'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(u'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': u'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())