class CourseView(LoginRequiredMixin, CourseValidMixin, CoursePermissionMixin, TemplateView): """ Base course view. Adds conveniences such as course_id attribute, and handles 404s when retrieving data from the API. """ client = None course = None course_id = None course_key = None user = None def dispatch(self, request, *args, **kwargs): self.user = request.user self.course_id = request.course_id self.course_key = request.course_key # some views will catch the NotFoundError to set data to a state that # the template can rendering a loading error message for the section try: return super(CourseView, self).dispatch(request, *args, **kwargs) except NotFoundError as e: logger.error('The requested data from the Analytics Data API was not found: %s', e) raise Http404 except ClientError as e: logger.error('An error occurred while retrieving data from the Analytics Data API: %s', e) raise def get_context_data(self, **kwargs): context = super(CourseView, self).get_context_data(**kwargs) self.client = Client(base_url=settings.DATA_API_URL, auth_token=settings.DATA_API_AUTH_TOKEN, timeout=5) self.course = self.client.courses(self.course_id) return context
class BasePresenter(object): """ This is the base class for the pages and sets up the analytics client for the presenters to use to access the data API. """ def __init__(self, course_id, timeout=settings.ANALYTICS_API_DEFAULT_TIMEOUT): self.client = Client(base_url=settings.DATA_API_URL, auth_token=settings.DATA_API_AUTH_TOKEN, timeout=timeout) self.course_id = course_id self.course = self.client.courses(self.course_id) def get_current_date(self): return datetime.datetime.utcnow().strftime(Client.DATE_FORMAT) @staticmethod def parse_api_date(s): """ Parse a string according to the API date format. """ return datetime.datetime.strptime(s, Client.DATE_FORMAT).date() @staticmethod def parse_api_datetime(s): """ Parse a string according to the API datetime format. """ return datetime.datetime.strptime(s, Client.DATETIME_FORMAT) @staticmethod def strip_time(s): return s[:-7] @staticmethod def sum_counts(data): return sum(datum['count'] for datum in data)
class BasePresenter(object): """ This is the base class for the pages and sets up the analytics client for the presenters to use to access the data API. """ def __init__(self, course_id, timeout=5): self.client = Client(base_url=settings.DATA_API_URL, auth_token=settings.DATA_API_AUTH_TOKEN, timeout=timeout) self.course_id = course_id self.course = self.client.courses(self.course_id) @staticmethod def parse_api_date(s): """ Parse a string according to the API date format. """ return datetime.datetime.strptime(s, Client.DATE_FORMAT).date() @staticmethod def parse_api_datetime(s): """ Parse a string according to the API datetime format. """ return datetime.datetime.strptime(s, Client.DATETIME_FORMAT) @staticmethod def strip_time(s): return s[:-7]
def _update_active_students(course_key, section_data): auth_token = settings.ANALYTICS_DATA_TOKEN base_url = settings.ANALYTICS_DATA_URL section_data['active_student_count'] = 'N/A' section_data['active_student_count_start'] = 'N/A' section_data['active_student_count_end'] = 'N/A' try: client = Client(base_url=base_url, auth_token=auth_token) course = client.courses(unicode(course_key)) recent_activity = course.recent_activity() section_data['active_student_count'] = recent_activity['count'] def format_date(value): return value.split('T')[0] start = recent_activity['interval_start'] end = recent_activity['interval_end'] section_data['active_student_count_start'] = format_date(start) section_data['active_student_count_end'] = format_date(end) except (ClientError, KeyError) as e: log.exception(e)
class CourseView(LoginRequiredMixin, CoursePermissionMixin, TemplateView): """ Base course view. Adds conveniences such as course_id attribute, and handles 404s when retrieving data from the API. """ client = None course = None course_id = None user = None def dispatch(self, request, *args, **kwargs): self.user = request.user self.course_id = kwargs['course_id'] try: return super(CourseView, self).dispatch(request, *args, **kwargs) except NotFoundError: raise Http404 def get_context_data(self, **kwargs): context = super(CourseView, self).get_context_data(**kwargs) self.client = Client(base_url=settings.DATA_API_URL, auth_token=settings.DATA_API_AUTH_TOKEN, timeout=5) self.course = self.client.courses(self.course_id) return context
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())
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())