""" 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 lms.lib.comment_client.utils import CommentClient500Error 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('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())