From daeabb06bf683b76c72bef409ce5edef30b596a2 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 10:12:10 -0400 Subject: [PATCH] add instructor dashboard beta (partial) (view, template, coffeescript, api endpoints) --- lms/djangoapps/analytics/__init__.py | 0 lms/djangoapps/analytics/basic.py | 82 +++++++++++ lms/djangoapps/analytics/csvs.py | 65 +++++++++ lms/djangoapps/analytics/distributions.py | 63 ++++++++ .../analytics/management/__init__.py | 0 .../analytics/management/commands/__init__.py | 0 lms/djangoapps/analytics/tests/__init__.py | 0 lms/djangoapps/courseware/tabs.py | 10 ++ lms/djangoapps/instructor/views/api.py | 134 ++++++++++++++++++ .../instructor/views/instructor_dashboard.py | 110 ++++++++++++++ .../coffee/src/instructor_dashboard.coffee | 120 ++++++++++++++++ lms/static/sass/course.scss.mako | 1 + .../sass/course/instructor/_instructor_2.scss | 77 ++++++++++ .../instructor_dashboard_2/course_info.html | 49 +++++++ .../instructor_dashboard_2.html | 79 +++++++++++ lms/urls.py | 12 ++ 16 files changed, 802 insertions(+) create mode 100644 lms/djangoapps/analytics/__init__.py create mode 100644 lms/djangoapps/analytics/basic.py create mode 100644 lms/djangoapps/analytics/csvs.py create mode 100644 lms/djangoapps/analytics/distributions.py create mode 100644 lms/djangoapps/analytics/management/__init__.py create mode 100644 lms/djangoapps/analytics/management/commands/__init__.py create mode 100644 lms/djangoapps/analytics/tests/__init__.py create mode 100644 lms/djangoapps/instructor/views/api.py create mode 100644 lms/djangoapps/instructor/views/instructor_dashboard.py create mode 100644 lms/static/coffee/src/instructor_dashboard.coffee create mode 100644 lms/static/sass/course/instructor/_instructor_2.scss create mode 100644 lms/templates/courseware/instructor_dashboard_2/course_info.html create mode 100644 lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html diff --git a/lms/djangoapps/analytics/__init__.py b/lms/djangoapps/analytics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/analytics/basic.py b/lms/djangoapps/analytics/basic.py new file mode 100644 index 0000000000..9acb505c4e --- /dev/null +++ b/lms/djangoapps/analytics/basic.py @@ -0,0 +1,82 @@ +""" +Student and course analytics. + +Serve miscellaneous course and student data +""" + +from django.contrib.auth.models import User +import xmodule.graders as xmgraders + + +AVAILABLE_STUDENT_FEATURES = ['username', 'first_name', 'last_name', 'is_staff', 'email'] +AVAILABLE_PROFILE_FEATURES = ['name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals'] + + +def enrolled_students_profiles(course_id, features): + """ + Return array of student features e.g. [{?}, ...] + """ + # enrollments = CourseEnrollment.objects.filter(course_id=course_id) + # students = [enrollment.user for enrollment in enrollments] + students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related('profile') + + def extract_student(student): + student_features = [feature for feature in features if feature in AVAILABLE_STUDENT_FEATURES] + profile_features = [feature for feature in features if feature in AVAILABLE_PROFILE_FEATURES] + + student_dict = dict((feature, getattr(student, feature)) for feature in student_features) + profile = student.profile + profile_dict = dict((feature, getattr(profile, feature)) for feature in profile_features) + student_dict.update(profile_dict) + return student_dict + + return [extract_student(student) for student in students] + + +def dump_grading_context(course): + """ + Dump information about course grading context (eg which problems are graded in what assignments) + Useful for debugging grading_policy.json and policy.json + + Returns HTML string + """ + msg = "-----------------------------------------------------------------------------\n" + msg += "Course grader:\n" + + msg += '%s\n' % course.grader.__class__ + graders = {} + if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader): + msg += '\n' + msg += "Graded sections:\n" + for subgrader, category, weight in course.grader.sections: + msg += " subgrader=%s, type=%s, category=%s, weight=%s\n" % (subgrader.__class__, subgrader.type, category, weight) + subgrader.index = 1 + graders[subgrader.type] = subgrader + msg += "-----------------------------------------------------------------------------\n" + msg += "Listing grading context for course %s\n" % course.id + + gc = course.grading_context + msg += "graded sections:\n" + + msg += '%s\n' % gc['graded_sections'].keys() + for (gs, gsvals) in gc['graded_sections'].items(): + msg += "--> Section %s:\n" % (gs) + for sec in gsvals: + s = sec['section_descriptor'] + format = getattr(s.lms, 'format', None) + aname = '' + if format in graders: + g = graders[format] + aname = '%s %02d' % (g.short_label, g.index) + g.index += 1 + elif s.display_name in graders: + g = graders[s.display_name] + aname = '%s' % g.short_label + notes = '' + if getattr(s, 'score_by_attempt', False): + notes = ', score by attempt!' + msg += " %s (format=%s, Assignment=%s%s)\n" % (s.display_name, format, aname, notes) + msg += "all descriptors:\n" + msg += "length=%d\n" % len(gc['all_descriptors']) + msg = '
%s
' % msg.replace('<','<') + return msg diff --git a/lms/djangoapps/analytics/csvs.py b/lms/djangoapps/analytics/csvs.py new file mode 100644 index 0000000000..ece486644f --- /dev/null +++ b/lms/djangoapps/analytics/csvs.py @@ -0,0 +1,65 @@ +""" +Student and course analytics. + +Format and create csv responses +""" + +import csv +from django.http import HttpResponse + + +def create_csv_response(filename, header, datarows): + """ + Create an HttpResponse with an attached .csv file + + header e.g. ['Name', 'Email'] + datarows e.g. [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ...] + """ + response = HttpResponse(mimetype='text/csv') + response['Content-Disposition'] = 'attachment; filename={0}'.format(filename) + csvwriter = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) + csvwriter.writerow(header) + for datarow in datarows: + encoded_row = [unicode(s).encode('utf-8') for s in datarow] + csvwriter.writerow(encoded_row) + return response + + +def format_dictlist(dictlist): + """ + Convert from [ + { + 'label1': 'value1,1', + 'label2': 'value2,1', + 'label3': 'value3,1', + 'label4': 'value4,1', + }, + { + 'label1': 'value1,2', + 'label2': 'value2,2', + 'label3': 'value3,2', + 'label4': 'value4,2', + } + ] + + to { + 'header': ['label1', 'label2', 'label3', 'label4'], + 'datarows': ['value1,1', 'value2,1', 'value3,1', 'value4,1'], ['value1,2', 'value2,2', 'value3,2', 'value4,2'] + } + + Do not handle empty lists. + """ + + header = dictlist[0].keys() + + def dict_to_entry(d): + ordered = sorted(d.items(), key=lambda (k, v): header.index(k)) + vals = map(lambda (k, v): v, ordered) + return vals + + datarows = map(dict_to_entry, dictlist) + + return { + 'header': header, + 'datarows': datarows, + } diff --git a/lms/djangoapps/analytics/distributions.py b/lms/djangoapps/analytics/distributions.py new file mode 100644 index 0000000000..d6c015b8e3 --- /dev/null +++ b/lms/djangoapps/analytics/distributions.py @@ -0,0 +1,63 @@ +""" +Profile Distributions +""" + +from django.db.models import Count +from django.contrib.auth.models import User, Group +from student.models import CourseEnrollment, UserProfile + +AVAILABLE_PROFILE_FEATURES = ['gender', 'level_of_education', 'year_of_birth'] + + +def profile_distribution(course_id, feature): + """ + Retrieve distribution of students over a given feature. + feature is one of AVAILABLE_PROFILE_FEATURES. + + Returna dictionary {'type': 'SOME_TYPE', 'data': {'key': 'val'}} + data types e.g. + EASY_CHOICE - choices with a restricted domain, e.g. level_of_education + OPEN_CHOICE - choices with a larger domain e.g. year_of_birth + """ + + EASY_CHOICE_FEATURES = ['gender', 'level_of_education'] + OPEN_CHOICE_FEATURES = ['year_of_birth'] + + feature_results = {} + + if not feature in AVAILABLE_PROFILE_FEATURES: + raise ValueError("unsupported feature requested for distribution '%s'" % feature) + + if feature in EASY_CHOICE_FEATURES: + if feature == 'gender': + choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] + elif feature == 'level_of_education': + choices = [(short, full) for (short, full) in UserProfile.LEVEL_OF_EDUCATION_CHOICES] + [(None, 'No Data')] + else: + raise ValueError("feature request not implemented for feature %s" % feature) + + data = {} + for (short, full) in choices: + if feature == 'gender': + count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__gender=short).count() + elif feature == 'level_of_education': + count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__level_of_education=short).count() + else: + raise ValueError("feature request not implemented for feature %s" % feature) + data[full] = count + + feature_results['data'] = data + feature_results['type'] = 'EASY_CHOICE' + elif feature in OPEN_CHOICE_FEATURES: + profiles = UserProfile.objects.filter(user__courseenrollment__course_id=course_id) + query_distribution = profiles.values('year_of_birth').annotate(Count('year_of_birth')).order_by() + # query_distribution is of the form [{'attribute': 'value1', 'attribute__count': 4}, {'attribute': 'value2', 'attribute__count': 2}, ...] + + distribution = dict((vald[feature], vald[feature + '__count']) for vald in query_distribution) + # distribution is of the form {'value1': 4, 'value2': 2, ...} + feature_results['data'] = distribution + feature_results['type'] = 'OPEN_CHOICE' + else: + raise ValueError("feature requested for distribution has not been implemented but is advertised in AVAILABLE_PROFILE_FEATURES! '%s'" % feature) + + return feature_results diff --git a/lms/djangoapps/analytics/management/__init__.py b/lms/djangoapps/analytics/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/analytics/management/commands/__init__.py b/lms/djangoapps/analytics/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/analytics/tests/__init__.py b/lms/djangoapps/analytics/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 8931f82724..389a1c4456 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -305,6 +305,11 @@ def get_course_tabs(user, course, active_page): tabs.append(CourseTab('Instructor', reverse('instructor_dashboard', args=[course.id]), active_page == 'instructor')) + + if has_access(user, course, 'staff'): + tabs.append(CourseTab('Instructor 2', + reverse('instructor_dashboard_2', args=[course.id]), + active_page == 'instructor_2')) return tabs @@ -356,6 +361,11 @@ def get_default_tabs(user, course, active_page): link = reverse('instructor_dashboard', args=[course.id]) tabs.append(CourseTab('Instructor', link, active_page == 'instructor')) + if has_access(user, course, 'staff'): + tabs.append(CourseTab('Instructor 2', + reverse('instructor_dashboard_2', args=[course.id]), + active_page == 'instructor_2')) + return tabs diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py new file mode 100644 index 0000000000..0606df5275 --- /dev/null +++ b/lms/djangoapps/instructor/views/api.py @@ -0,0 +1,134 @@ +""" +Instructor Dashboard API views + +Non-html views which the instructor dashboard requests. + +TODO add tracking +""" + +import csv +import json +import logging +import os +import re +import requests +from django_future.csrf import ensure_csrf_cookie +from django.views.decorators.cache import cache_control +from mitxmako.shortcuts import render_to_response +from django.core.urlresolvers import reverse +from django.utils.html import escape +from django.http import HttpResponse, HttpResponseBadRequest + +from django.conf import settings +from courseware.access import has_access, get_access_group_name, course_beta_test_group_name +from courseware.courses import get_course_with_access +from django_comment_client.utils import has_forum_access +from instructor.offline_gradecalc import student_grades, offline_grades_available +from django_comment_common.models import Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA +from xmodule.modulestore.django import modulestore +from student.models import CourseEnrollment +from django.contrib.auth.models import User + +import analytics.basic +import analytics.distributions +import analytics.csvs + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def grading_config(request, course_id): + """ + Respond with json which contains a html formatted grade summary. + + TODO maybe this shouldn't be html already + """ + course = get_course_with_access(request.user, course_id, 'staff', depth=None) + grading_config_summary = analytics.basic.dump_grading_context(course) + + response_payload = { + 'course_id': course_id, + 'grading_config_summary': grading_config_summary, + } + response = HttpResponse(json.dumps(response_payload), content_type="application/json") + return response + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def enrolled_students_profiles(request, course_id, csv=False): + """ + Respond with json which contains a summary of all enrolled students profile information. + + Response {"students": [{-student-info-}, ...]} + + TODO accept requests for different attribute sets + """ + + available_features = analytics.basic.AVAILABLE_STUDENT_FEATURES + analytics.basic.AVAILABLE_PROFILE_FEATURES + query_features = ['username', 'name', 'language', 'location', 'year_of_birth', 'gender', + 'level_of_education', 'mailing_address', 'goals'] + + student_data = analytics.basic.enrolled_students_profiles(course_id, query_features) + + if not csv: + response_payload = { + 'course_id': course_id, + 'students': student_data, + 'students_count': len(student_data), + 'available_features': available_features + } + response = HttpResponse(json.dumps(response_payload), content_type="application/json") + return response + else: + formatted = analytics.csvs.format_dictlist(student_data) + return analytics.csvs.create_csv_response("enrolled_profiles.csv", formatted['header'], formatted['datarows']) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def profile_distribution(request, course_id): + """ + Respond with json of the distribution of students over selected fields which have choices. + + Ask for features through the 'features' query parameter. + The features query parameter can be either a single feature name, or a json string of feature names. + e.g. + http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution?features=level_of_education + http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution?features=%5B%22year_of_birth%22%2C%22gender%22%5D + + Example js query: + $.get("http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution", + {'features': JSON.stringify(['year_of_birth', 'gender'])}, + function(){console.log(arguments[0])}) + + TODO how should query parameter interpretation work? + TODO respond to csv requests as well + """ + + try: + features = json.loads(request.GET.get('features')) + except Exception: + features = [request.GET.get('features')] + + feature_results = {} + + for feature in features: + try: + feature_results[feature] = analytics.distributions.profile_distribution(course_id, feature) + except Exception as e: + feature_results[feature] = {'error': "can not find distribution for '%s'" % feature} + raise e + + response_payload = { + 'course_id': course_id, + 'queried_features': features, + 'available_features': analytics.distributions.AVAILABLE_PROFILE_FEATURES, + 'display_names': { + 'gender': 'Gender', + 'level_of_education': 'Level of Education', + 'year_of_birth': 'Year Of Birth', + }, + 'feature_results': feature_results, + } + response = HttpResponse(json.dumps(response_payload), content_type="application/json") + return response diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py new file mode 100644 index 0000000000..97059670da --- /dev/null +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -0,0 +1,110 @@ +""" +Instructor Dashboard Views + +TODO add tracking +""" + +import csv +import json +import logging +import os +import re +import requests +from django_future.csrf import ensure_csrf_cookie +from django.views.decorators.cache import cache_control +from mitxmako.shortcuts import render_to_response +from django.core.urlresolvers import reverse +from django.utils.html import escape + +from django.conf import settings +from courseware.access import has_access, get_access_group_name, course_beta_test_group_name +from courseware.courses import get_course_with_access +from django_comment_client.utils import has_forum_access +from instructor.offline_gradecalc import student_grades, offline_grades_available +from django_comment_common.models import Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA +from xmodule.modulestore.django import modulestore +from student.models import CourseEnrollment + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def instructor_dashboard_2(request, course_id): + """Display the instructor dashboard for a course.""" + + course = get_course_with_access(request.user, course_id, 'staff', depth=None) + instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists + forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR) + + section_data = { + 'course_info': _section_course_info(request, course_id), + 'enrollment': _section_enrollment(course_id), + 'student_admin': _section_student_admin(course_id), + 'data_download': _section_data_download(course_id), + 'analytics': _section_analytics(course_id), + } + + context = { + 'course': course, + 'staff_access': True, + 'admin_access': request.user.is_staff, + 'instructor_access': instructor_access, + 'forum_admin_access': forum_admin_access, + 'djangopid': os.getpid(), + 'mitx_version': getattr(settings, 'MITX_VERSION_STRING', ''), + 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), + 'section_data': section_data + } + + return render_to_response('courseware/instructor_dashboard_2/instructor_dashboard_2.html', context) + + +def _section_course_info(request, course_id): + """ Provide data for the corresponding dashboard section """ + course = get_course_with_access(request.user, course_id, 'staff', depth=None) + + section_data = {} + section_data['course_id'] = course_id + section_data['display_name'] = course.display_name + section_data['enrollment_count'] = CourseEnrollment.objects.filter(course_id=course_id).count() + section_data['has_started'] = course.has_started() + section_data['has_ended'] = course.has_ended() + section_data['grade_cutoffs'] = "[" + reduce(lambda memo, (letter, score): "{}: {}, ".format(letter, score) + memo , course.grade_cutoffs.items(), "")[:-2] + "]" + section_data['offline_grades'] = offline_grades_available(course_id) + + try: + section_data['course_errors'] = [(escape(a), '') for (a,b) in modulestore().get_item_errors(course.location)] + except Exception: + section_data['course_errors'] = [('Error fetching errors', '')] + + return section_data + + +def _section_enrollment(course_id): + """ Provide data for the corresponding dashboard section """ + section_data = {} + section_data['placeholder'] = "Enrollment content." + return section_data + + +def _section_student_admin(course_id): + """ Provide data for the corresponding dashboard section """ + section_data = {} + section_data['placeholder'] = "Student Admin content." + return section_data + + +def _section_data_download(course_id): + """ Provide data for the corresponding dashboard section """ + section_data = { + 'grading_config_url': reverse('grading_config', kwargs={'course_id': course_id}), + 'enrolled_students_profiles_url': reverse('enrolled_students_profiles', kwargs={'course_id': course_id}), + } + return section_data + + +def _section_analytics(course_id): + """ Provide data for the corresponding dashboard section """ + section_data = { + 'profile_distributions_url': reverse('profile_distribution', kwargs={'course_id': course_id}), + } + return section_data diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee new file mode 100644 index 0000000000..78c731f38f --- /dev/null +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -0,0 +1,120 @@ +# Instructor Dashboard Tab Manager + +log = -> console.log.apply console, arguments + +CSS_INSTRUCTOR_CONTENT = 'instructor-dashboard-content-2' +CSS_ACTIVE_SECTION = 'active-section' +CSS_IDASH_SECTION = 'idash-section' +CSS_IDASH_DEFAULT_SECTION = 'idash-default-section' +CSS_INSTRUCTOR_NAV = 'instructor-nav' + +HASH_LINK_PREFIX = '#view-' + + +# once we're ready, check if this page has the instructor dashboard +$ => + instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}" + if instructor_dashboard_content.length != 0 + log "setting up instructor dashboard" + setup_instructor_dashboard instructor_dashboard_content + setup_instructor_dashboard_sections instructor_dashboard_content + + +# enable links +setup_instructor_dashboard = (idash_content) => + links = idash_content.find(".#{CSS_INSTRUCTOR_NAV}").find('a') + # setup section header click handlers + for link in ($ link for link in links) + link.click (e) -> + # deactivate (styling) all sections + idash_content.find(".#{CSS_IDASH_SECTION}").removeClass CSS_ACTIVE_SECTION + idash_content.find(".#{CSS_INSTRUCTOR_NAV}").children().removeClass CSS_ACTIVE_SECTION + + # find paired section + section_name = $(this).data 'section' + section = idash_content.find "##{section_name}" + + # activate (styling) active + section.addClass CSS_ACTIVE_SECTION + $(this).addClass CSS_ACTIVE_SECTION + + # write deep link + location.hash = "#{HASH_LINK_PREFIX}#{section_name}" + + log "clicked #{section_name}" + e.preventDefault() + + # recover deep link from url + # click default or go to section specified by hash + if (new RegExp "^#{HASH_LINK_PREFIX}").test location.hash + rmatch = (new RegExp "^#{HASH_LINK_PREFIX}(.*)").exec location.hash + section_name = rmatch[1] + link = links.filter "[data-section='#{section_name}']" + link.click() + else + links.filter(".#{CSS_IDASH_DEFAULT_SECTION}").click() + + +# call setup handlers for each section +setup_instructor_dashboard_sections = (idash_content) -> + log "setting up instructor dashboard sections" + setup_section_data_download idash_content.find(".#{CSS_IDASH_SECTION}#data-download") + setup_section_analytics idash_content.find(".#{CSS_IDASH_SECTION}#analytics") + + +# setup the data download section +setup_section_data_download = (section) -> + list_studs_btn = section.find("input[name='list-profiles']'") + list_studs_btn.click (e) -> + log "fetching student list" + url = $(this).data('endpoint') + if $(this).data 'csv' + url += '/csv' + location.href = url + else + $.getJSON url, (data) -> + section.find('.dumped-data-display').text JSON.stringify(data) + + grade_config_btn = section.find("input[name='dump-gradeconf']'") + grade_config_btn.click (e) -> + log "fetching grading config" + url = $(this).data('endpoint') + $.getJSON url, (data) -> + section.find('.dumped-data-display').html data['grading_config_summary'] + + +# setup the analytics section +setup_section_analytics = (section) -> + log "setting up instructor dashboard section - analytics" + + distribution_select = section.find('select#distributions') + # ask for available distributions + $.getJSON distribution_select.data('endpoint'), features: JSON.stringify([]), (data) -> + distribution_select.find('option').eq(0).text "-- Select distribution" + + for feature in data.available_features + opt = $ '