From 02432e05cc041d269c4987e45994694fbc9890c7 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 15:33:11 -0400 Subject: [PATCH] integrate slickgrid, add instructor.enrollment, add instructor.access, refactor & clean --- lms/djangoapps/analytics/distributions.py | 2 +- lms/djangoapps/instructor/access.py | 49 +++++ lms/djangoapps/instructor/enrollment.py | 138 +++++++++++++ lms/djangoapps/instructor/views/api.py | 84 ++++++-- .../instructor/views/instructor_dashboard.py | 64 ++++-- .../coffee/src/instructor_dashboard.coffee | 192 +++++++++++++++--- .../sass/course/instructor/_instructor_2.scss | 91 ++++++++- .../instructor_dashboard_2/analytics.html | 11 + .../instructor_dashboard_2/course_info.html | 39 ++-- .../instructor_dashboard_2/data_download.html | 11 + .../instructor_dashboard_2/enrollment.html | 10 + .../instructor_dashboard_2.html | 62 ++---- .../instructor_dashboard_2/student_admin.html | 23 +++ lms/urls.py | 4 + 14 files changed, 648 insertions(+), 132 deletions(-) create mode 100644 lms/djangoapps/instructor/access.py create mode 100644 lms/djangoapps/instructor/enrollment.py create mode 100644 lms/templates/courseware/instructor_dashboard_2/analytics.html create mode 100644 lms/templates/courseware/instructor_dashboard_2/data_download.html create mode 100644 lms/templates/courseware/instructor_dashboard_2/enrollment.html create mode 100644 lms/templates/courseware/instructor_dashboard_2/student_admin.html diff --git a/lms/djangoapps/analytics/distributions.py b/lms/djangoapps/analytics/distributions.py index d6c015b8e3..6e4babb871 100644 --- a/lms/djangoapps/analytics/distributions.py +++ b/lms/djangoapps/analytics/distributions.py @@ -50,7 +50,7 @@ def profile_distribution(course_id, feature): 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 = profiles.values(feature).annotate(Count(feature)).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) diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py new file mode 100644 index 0000000000..ce3419ece2 --- /dev/null +++ b/lms/djangoapps/instructor/access.py @@ -0,0 +1,49 @@ +""" +Access control operations for use by instructor APIs. + +Does not include any access control, be sure to check access before calling. + +TODO sync instructor and staff flags + e.g. should these be possible? + {instructor: true, staff: false} + {instructor: true, staff: true} +""" + +from django.contrib.auth.models import User, Group +from courseware.access import get_access_group_name + + +def allow_access(course, user, level): + """ + Allow user access to course modification. + + level is one of ['instructor', 'staff'] + """ + _change_access(course, user, level, 'allow') + + +def revoke_access(course, user, level): + """ + Revoke access from user to course modification. + + level is one of ['instructor', 'staff'] + """ + _change_access(course, user, level, 'revoke') + + +def _change_access(course, user, level, mode): + """ + Change access of user. + + level is one of ['instructor', 'staff'] + mode is one of ['allow', 'revoke'] + """ + grpname = get_access_group_name(course, level) + group, _ = Group.objects.get_or_create(name=grpname) + + if mode == 'allow': + user.groups.add(group) + elif mode == 'revoke': + user.groups.remove(group) + else: + raise ValueError("unrecognized mode '{}'".format(mode)) diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py new file mode 100644 index 0000000000..ad195182c1 --- /dev/null +++ b/lms/djangoapps/instructor/enrollment.py @@ -0,0 +1,138 @@ +""" +Enrollment operations for use by instructor APIs. + +Does not include any access control, be sure to check access before calling. +""" + +import re +from django.contrib.auth.models import User +from student.models import CourseEnrollment, CourseEnrollmentAllowed + + +def enroll_emails(course_id, student_emails, auto_enroll=False): + """ + Enroll multiple students by email. + + students is a list of student emails e.g. ["foo@bar.com", "bar@foo.com] + each of whom possibly does not exist in db. + + status contains the relevant prior state and action performed on the user. + ce stands for CourseEnrollment + cea stands for CourseEnrollmentAllowed + ! stands for the object not existing prior to the action + return a mapping from status to emails. + """ + + auto_string = {False: 'allowed', True: 'willautoenroll'}[auto_enroll] + + status_map = { + 'user/ce/alreadyenrolled': [], + 'user/!ce/enrolled': [], + 'user/!ce/rejected': [], + '!user/cea/' + auto_string: [], + '!user/!cea/' + auto_string: [], + } + + for student_email in student_emails: + # status: user + try: + user = User.objects.get(email=student_email) + + # status: user/ce + try: + CourseEnrollment.objects.get(user=user, course_id=course_id) + status_map['user/ce/alreadyenrolled'].append(student_email) + # status: user/!ce + except CourseEnrollment.DoesNotExist: + # status: user/!ce/enrolled + try: + ce = CourseEnrollment(user=user, course_id=course_id) + ce.save() + status_map['user/!ce/enrolled'].append(student_email) + # status: user/!ce/rejected + except: + status_map['user/!ce/rejected'].append(student_email) + # status: !user + except User.DoesNotExist: + # status: !user/cea + try: + cea = CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email) + cea.auto_enroll = auto_enroll + cea.save() + status_map['!user/cea/' + auto_string].append(student_email) + # status: !user/!cea + except CourseEnrollmentAllowed.DoesNotExist: + cea = CourseEnrollmentAllowed(course_id=course_id, email=student_email, auto_enroll=auto_enroll) + cea.save() + status_map['!user/!cea/' + auto_string].append(student_email) + + return status_map + + +def unenroll_emails(course_id, student_emails): + """ + Unenroll multiple students by email. + + students is a list of student emails e.g. ["foo@bar.com", "bar@foo.com] + each of whom possibly does not exist in db. + + Fail quietly on student emails that do not match any users or allowed enrollments. + + status contains the relevant prior state and action performed on the user. + ce stands for CourseEnrollment + cea stands for CourseEnrollmentAllowed + ! stands for the object not existing prior to the action + return a mapping from status to emails. + """ + + # NOTE these are not mutually exclusive + status_map = { + 'cea/disallowed': [], + 'ce/unenrolled': [], + 'ce/rejected': [], + '!ce/notenrolled': [], + } + + for student_email in student_emails: + # delete CourseEnrollmentAllowed + try: + cea = CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email) + cea.delete() + status_map['cea/disallowed'].append(student_email) + except CourseEnrollmentAllowed.DoesNotExist: + pass + + # delete CourseEnrollment + try: + ce = CourseEnrollment.objects.get(course_id=course_id, user__email=student_email) + try: + ce.delete() + status_map['ce/unenrolled'].append(student_email) + except Exception: + status_map['ce/rejected'].append(student_email) + except CourseEnrollment.DoesNotExist: + status_map['!ce/notenrolled'].append(student_email) + + return status_map + + +def split_input_list(str_list): + """ + Separate out individual student email from the comma, or space separated string. + + e.g. + in: "Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed" + out: ['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed'] + + In: + students: string coming from the input text area + Return: + students: list of cleaned student emails + students_lc: list of lower case cleaned student emails + """ + + new_list = re.split(r'[\n\r\s,]', str_list) + new_list = [str(s.strip()) for s in new_list] + new_list = [s for s in new_list if s != ''] + + return new_list diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 0606df5275..0da4c715fd 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -4,36 +4,79 @@ Instructor Dashboard API views Non-html views which the instructor dashboard requests. TODO add tracking +TODO a lot of these GETs should be PUTs """ -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.http import HttpResponse -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 +from django.contrib.auth.models import User, Group +from instructor.enrollment import split_input_list, enroll_emails, unenroll_emails +from instructor.access import allow_access, revoke_access import analytics.basic import analytics.distributions import analytics.csvs +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def enroll_unenroll(request, course_id): + """ + Enroll or unenroll students by email. + """ + course = get_course_with_access(request.user, course_id, 'staff', depth=None) + + emails_to_enroll = split_input_list(request.GET.get('enroll', '')) + emails_to_unenroll = split_input_list(request.GET.get('unenroll', '')) + + enrolled_result = enroll_emails(course_id, emails_to_enroll) + unenrolled_result = unenroll_emails(course_id, emails_to_unenroll) + + response_payload = { + 'enrolled': enrolled_result, + 'unenrolled': unenrolled_result, + } + 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 access_allow_revoke(request, course_id): + """ + Modify staff/instructor access. (instructor available only) + + Query parameters: + email is the target users email + level is one of ['instructor', 'staff'] + mode is one of ['allow', 'revoke'] + """ + course = get_course_with_access(request.user, course_id, 'instructor', depth=None) + + email = request.GET.get('email') + level = request.GET.get('level') + mode = request.GET.get('mode') + + user = User.objects.get(email=email) + + if mode == 'allow': + allow_access(course, user, level) + elif mode == 'revoke': + revoke_access(course, user, level) + else: + raise ValueError("unrecognized mode '{}'".format(mode)) + + response_payload = { + 'done': 'yup', + } + 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 grading_config(request, course_id): @@ -63,9 +106,10 @@ def enrolled_students_profiles(request, course_id, csv=False): TODO accept requests for different attribute sets """ + course = get_course_with_access(request.user, course_id, 'staff', depth=None) available_features = analytics.basic.AVAILABLE_STUDENT_FEATURES + analytics.basic.AVAILABLE_PROFILE_FEATURES - query_features = ['username', 'name', 'language', 'location', 'year_of_birth', 'gender', + query_features = ['username', 'name', 'email', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals'] student_data = analytics.basic.enrolled_students_profiles(course_id, query_features) @@ -75,7 +119,8 @@ def enrolled_students_profiles(request, course_id, csv=False): 'course_id': course_id, 'students': student_data, 'students_count': len(student_data), - 'available_features': available_features + 'queried_features': query_features, + 'available_features': available_features, } response = HttpResponse(json.dumps(response_payload), content_type="application/json") return response @@ -104,6 +149,7 @@ def profile_distribution(request, course_id): TODO how should query parameter interpretation work? TODO respond to csv requests as well """ + course = get_course_with_access(request.user, course_id, 'staff', depth=None) try: features = json.loads(request.GET.get('features')) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 97059670da..ea60f465af 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -15,10 +15,11 @@ 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 Http404 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 courseware.courses import get_course_by_id 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 @@ -31,17 +32,21 @@ from student.models import CourseEnrollment 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) + course = get_course_by_id(course_id, depth=None) instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists + staff_access = has_access(request.user, course, 'staff') 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), - } + if not staff_access: + raise Http404 + + sections = [ + _section_course_info(course_id), + _section_enrollment(course_id), + _section_student_admin(course_id), + _section_data_download(course_id), + _section_analytics(course_id), + ] context = { 'course': course, @@ -52,19 +57,34 @@ def instructor_dashboard_2(request, course_id): 'djangopid': os.getpid(), 'mitx_version': getattr(settings, 'MITX_VERSION_STRING', ''), 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), - 'section_data': section_data + 'sections': sections } return render_to_response('courseware/instructor_dashboard_2/instructor_dashboard_2.html', context) -def _section_course_info(request, course_id): +""" +Section functions starting with _section return a dictionary of section data. + +The dictionary must include at least { + 'section_key': 'circus_expo' + 'section_display_name': 'Circus Expo' +} + +section_display_name will be used to generate link titles in the nav bar. +sek will be used as a css attribute, javascript tie-in, and template import filename. +""" + + +def _section_course_info(course_id): """ Provide data for the corresponding dashboard section """ - course = get_course_with_access(request.user, course_id, 'staff', depth=None) + course = get_course_by_id(course_id, depth=None) section_data = {} + section_data['section_key'] = 'course_info' + section_data['section_display_name'] = 'Course Info' section_data['course_id'] = course_id - section_data['display_name'] = course.display_name + section_data['course_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() @@ -81,21 +101,29 @@ def _section_course_info(request, course_id): def _section_enrollment(course_id): """ Provide data for the corresponding dashboard section """ - section_data = {} - section_data['placeholder'] = "Enrollment content." + section_data = { + 'section_key': 'enrollment', + 'section_display_name': 'Enrollment', + 'enroll_button_url': reverse('enroll_unenroll', kwargs={'course_id': course_id}), + 'unenroll_button_url': reverse('enroll_unenroll', kwargs={'course_id': course_id}), + } return section_data def _section_student_admin(course_id): """ Provide data for the corresponding dashboard section """ - section_data = {} - section_data['placeholder'] = "Student Admin content." + section_data = { + 'section_key': 'student_admin', + 'section_display_name': 'Student Admin', + } return section_data def _section_data_download(course_id): """ Provide data for the corresponding dashboard section """ section_data = { + 'section_key': 'data_download', + 'section_display_name': 'Data Download', 'grading_config_url': reverse('grading_config', kwargs={'course_id': course_id}), 'enrolled_students_profiles_url': reverse('enrolled_students_profiles', kwargs={'course_id': course_id}), } @@ -105,6 +133,8 @@ def _section_data_download(course_id): def _section_analytics(course_id): """ Provide data for the corresponding dashboard section """ section_data = { + 'section_key': 'analytics', + 'section_display_name': 'Analytics', '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 index 9caccf8c2c..8f41ed1b22 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -5,7 +5,6 @@ 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-' @@ -52,18 +51,117 @@ setup_instructor_dashboard = (idash_content) => link = links.filter "[data-section='#{section_name}']" link.click() else - links.filter(".#{CSS_IDASH_DEFAULT_SECTION}").click() + links.eq(0).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_enrollment idash_content.find(".#{CSS_IDASH_SECTION}#enrollment") + 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_enrollment = (section) -> + log "setting up instructor dashboard section - enrollment" + + emails_input = section.find("textarea[name='student-emails']'") + btn_enroll = section.find("input[name='enroll']'") + btn_unenroll = section.find("input[name='unenroll']'") + task_response = section.find(".task-response") + + emails_input.click -> log 'click emails_input' + btn_enroll.click -> log 'click btn_enroll' + btn_unenroll.click -> log 'click btn_unenroll' + + btn_enroll.click -> $.getJSON btn_enroll.data('endpoint'), enroll: emails_input.val() , (data) -> + log 'received response for enroll button', data + display_response(data) + + btn_unenroll.click -> $.getJSON btn_unenroll.data('endpoint'), unenroll: emails_input.val() , (data) -> + log 'received response for unenroll button', data + display_response(data) + + display_response = (data_from_server) -> + task_response.empty() + + response_code_dict = _.extend {}, data_from_server.enrolled, data_from_server.unenrolled + # response_code_dict e.g. {'code': ['email1', 'email2'], ...} + message_ordering = [ + 'msg_error_enroll' + 'msg_error_unenroll' + 'msg_enrolled' + 'msg_unenrolled' + 'msg_willautoenroll' + 'msg_allowed' + 'msg_disallowed' + 'msg_already_enrolled' + 'msg_notenrolled' + ] + + msg_to_txt = { + msg_already_enrolled: "Already enrolled:" + msg_enrolled: "Enrolled:" + msg_error_enroll: "There was an error enrolling these students:" + msg_allowed: "These students will be allowed to enroll once they register:" + msg_willautoenroll: "These students will be enrolled once they register:" + msg_unenrolled: "Unenrolled:" + msg_error_unenroll: "There was an error unenrolling these students:" + msg_disallowed: "These students were removed from those who can enroll once they register:" + msg_notenrolled: "These students were not enrolled:" + } + + msg_to_codes = { + msg_already_enrolled: ['user/ce/alreadyenrolled'] + msg_enrolled: ['user/!ce/enrolled'] + msg_error_enroll: ['user/!ce/rejected'] + msg_allowed: ['!user/cea/allowed', '!user/!cea/allowed'] + msg_willautoenroll: ['!user/cea/willautoenroll', '!user/!cea/willautoenroll'] + msg_unenrolled: ['ce/unenrolled'] + msg_error_unenroll: ['ce/rejected'] + msg_disallowed: ['cea/disallowed'] + msg_notenrolled: ['!ce/notenrolled'] + } + + for msg_symbol in message_ordering + # task_response.text JSON.stringify(data) + msg_txt = msg_to_txt[msg_symbol] + task_res_section = $ '
', class: 'task-res-section' + task_res_section.append $ '

', text: msg_txt + email_list = $ '
    ' + task_res_section.append email_list + will_attach = false + + for code in msg_to_codes[msg_symbol] + log 'logging code', code + emails = response_code_dict[code] + log 'emails', emails + + if emails and emails.length + for email in emails + log 'logging email', email + email_list.append $ '
  • ', text: email + will_attach = true + + if will_attach + task_response.append task_res_section + else + task_res_section.remove() + + # setup the data download section setup_section_data_download = (section) -> + log "setting up instructor dashboard section - data download" + + display = section.find('.data-display') + display_text = display.find('.data-display-text') + display_table = display.find('.data-display-table') + + reset_display = -> + display_text.empty() + display_table.empty() + list_studs_btn = section.find("input[name='list-profiles']'") list_studs_btn.click (e) -> log "fetching student list" @@ -72,43 +170,47 @@ setup_section_data_download = (section) -> url += '/csv' location.href = url else + reset_display() $.getJSON url, (data) -> - display = section.find('.dumped-data-display') - display.text JSON.stringify(data) - log data - # setup SlickGrid + options = + enableCellNavigation: true + enableColumnReorder: false - options = enableCellNavigation: true, enableColumnReorder: false - # columns = [{id: feature, name: feature} for feature in data.queried_features] + columns = ({id: feature, field: feature, name: feature} for feature in data.queried_features) + grid_data = data.students - log options - # log columns - - # new Slick.Grid(display, data.students, columns, options) - - data = [{'label1': 'val1,1', 'label2': 'val2,1'}, {'label1': 'val1,2', 'label2': 'val2,2'}] - columns = [{id: 'label1', name: 'Label One', width: 80, minWidth: 80}, {id: 'label2', name: 'Label Two'}] - - log 'columns', columns - log 'data', data - - grid = new Slick.Grid(display, data, columns, options) + table_placeholder = $ '
    ', class: 'slickgrid' + display_table.append table_placeholder + grid = new Slick.Grid(table_placeholder, grid_data, columns, options) grid.autosizeColumns() + 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'] + reset_display() + display_text.html data['grading_config_summary'] # setup the analytics section setup_section_analytics = (section) -> log "setting up instructor dashboard section - analytics" + display = section.find('.distribution-display') + display_text = display.find('.distribution-display-text') + display_graph = display.find('.distribution-display-graph') + display_table = display.find('.distribution-display-table') + + reset_display = -> + display_text.empty() + display_graph.empty() + display_table.empty() + 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" @@ -125,17 +227,55 @@ setup_section_analytics = (section) -> opt = $(this).children('option:selected') log "distribution selected: #{opt.data 'feature'}" feature = opt.data 'feature' + reset_display() $.getJSON distribution_select.data('endpoint'), features: JSON.stringify([feature]), (data) -> feature_res = data.feature_results[feature] # feature response format: {'error': 'optional error string', 'type': 'SOME_TYPE', 'data': [stuff]} - display = section.find('.distribution-display').eq(0) if feature_res.error console.warn(feature_res.error) - display.text 'Error fetching data' + display_text.text 'Error fetching data' else if feature_res.type is 'EASY_CHOICE' - display.text JSON.stringify(feature_res.data) + # display_text.text JSON.stringify(feature_res.data) log feature_res.data + + # setup SlickGrid + options = + enableCellNavigation: true + enableColumnReorder: false + + columns = [ + id: feature + field: feature + name: feature + , + id: 'count' + field: 'count' + name: 'Count' + ] + + grid_data = _.map feature_res.data, (value, key) -> + datapoint = {} + datapoint[feature] = key + datapoint['count'] = value + datapoint + + log grid_data + + table_placeholder = $ '
    ', class: 'slickgrid' + display_table.append table_placeholder + grid = new Slick.Grid(table_placeholder, grid_data, columns, options) + grid.autosizeColumns() + else if feature is 'year_of_birth' + graph_placeholder = $ '
    ', class: 'year-of-birth' + display_graph.append graph_placeholder + + graph_data = _.map feature_res.data, (value, key) -> [parseInt(key), value] + log graph_data + + $.plot graph_placeholder, [ + data: graph_data + ] else console.warn("don't know how to show #{feature_res.type}") - display.text 'Unavailable Metric' + display_text.text 'Unavailable Metric\n' + JSON.stringify(feature_res) diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 89e9830cc6..fd26641561 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -8,8 +8,14 @@ width: 100%; position: relative; + .slick-header-column { + height: 100%; + } + h1 { @extend .top-header; + border-bottom: 0; + padding-bottom: 0; } .instructor_dash_glob_info { @@ -20,9 +26,16 @@ } .instructor-nav { + a { + margin-right: 1.2em; + } + .active-section { color: #551A8B; } + + border-bottom: 1px solid #C8C8C8; + padding-bottom: 1em; } section.idash-section { @@ -42,7 +55,7 @@ } -.instructor-dashboard-wrapper-2 section.idash-section#course-info { +.instructor-dashboard-wrapper-2 section.idash-section#course_info { .error-log { margin-top: 1em; @@ -63,15 +76,77 @@ } -.instructor-dashboard-wrapper-2 section.idash-section#data-download { - input { - display: block; - margin-bottom: 1em; +.instructor-dashboard-wrapper-2 section.idash-section#enrollment { + div { + margin-top: 2em; + } + + textarea { + height: 100px; + width: 500px; + } + + .task-res-section { + h3 { + color: #646464; + } + + ul { + padding: 0; + margin: 0; + margin-top: 0.5em; + line-height: 1.5em; + list-style-type: none; + li { + } + } } } -.instructor-dashboard-wrapper-2 section.idash-section#analytics { - .distribution-display { - margin-top: 1em; + +.instructor-dashboard-wrapper-2 section.idash-section#student_admin { + .h-row { + margin-bottom: 1em; + clear: both; + } + + p input select { + float: left; + } +} + + +.instructor-dashboard-wrapper-2 section.idash-section#data_download { + input { + // display: block; + margin-bottom: 1em; + } + + .data-display { + .data-display-table { + .slickgrid { + height: 400px; + } + } + } +} + + +.instructor-dashboard-wrapper-2 section.idash-section#analytics { + .distribution-display { + margin-top: 1.2em; + + .distribution-display-graph { + .year-of-birth { + width: 500px; + height: 200px; + } + } + + .distribution-display-table { + .slickgrid { + height: 400px; + } + } } } diff --git a/lms/templates/courseware/instructor_dashboard_2/analytics.html b/lms/templates/courseware/instructor_dashboard_2/analytics.html new file mode 100644 index 0000000000..d8defba74c --- /dev/null +++ b/lms/templates/courseware/instructor_dashboard_2/analytics.html @@ -0,0 +1,11 @@ +<%page args="section_data"/> + +

    Distributions

    + +
    +
    +
    +
    +
    diff --git a/lms/templates/courseware/instructor_dashboard_2/course_info.html b/lms/templates/courseware/instructor_dashboard_2/course_info.html index 2b4fa58158..9dcfa9a390 100644 --- a/lms/templates/courseware/instructor_dashboard_2/course_info.html +++ b/lms/templates/courseware/instructor_dashboard_2/course_info.html @@ -1,49 +1,50 @@ +<%page args="section_data"/> + +

    Course Information

    +
    Course Name: - ${ section_data['course_info']['display_name'] } + ${ section_data['course_display_name'] }
    Course ID: - ${ section_data['course_info']['course_id'] } + ${ section_data['course_id'] }
    Students Enrolled: - ${ section_data['course_info']['enrollment_count'] } + ${ section_data['enrollment_count'] }
    Started: - ${ section_data['course_info']['has_started'] } + ${ section_data['has_started'] }
    Ended: - ${ section_data['course_info']['has_ended'] } + ${ section_data['has_ended'] }
    Grade Cutoffs: - ${ section_data['course_info']['grade_cutoffs'] } + ${ section_data['grade_cutoffs'] }
    Offline Grades Available: - ${ section_data['course_info']['offline_grades'] } + ${ section_data['offline_grades'] }
    -

    Course Errors:

    - %for error in section_data['course_info']['course_errors']: -
    - ${ error[0] }
    - ${ error[1] } -
    - %endfor + %if len(section_data['course_errors']): +

    Course Errors:

    + %for error in section_data['course_errors']: +
    + ${ error[0] }
    + ${ error[1] } +
    + %endfor + %endif
    - -##
    -## Section Dump
    -## ${ section_data['course_info'] } -##
    diff --git a/lms/templates/courseware/instructor_dashboard_2/data_download.html b/lms/templates/courseware/instructor_dashboard_2/data_download.html new file mode 100644 index 0000000000..508d3c9f95 --- /dev/null +++ b/lms/templates/courseware/instructor_dashboard_2/data_download.html @@ -0,0 +1,11 @@ +<%page args="section_data"/> + + + + + + +
    +
    +
    +
    diff --git a/lms/templates/courseware/instructor_dashboard_2/enrollment.html b/lms/templates/courseware/instructor_dashboard_2/enrollment.html new file mode 100644 index 0000000000..d31e2ea10b --- /dev/null +++ b/lms/templates/courseware/instructor_dashboard_2/enrollment.html @@ -0,0 +1,10 @@ +<%page args="section_data"/> + +
    +

    Batch Enrollment

    +

    Enter student emails separated by new lines or commas.

    + + + +
    +
    diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index c4e0e434c8..7e35849d9c 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -10,7 +10,12 @@ - + + + + + + <%include file="/courseware/course_navigation.html" args="active_page='instructor_2'" /> @@ -24,55 +29,28 @@

    Instructor Dashboard

    -
    - ${djangopid} | - ${mitx_version} -
    + ##
    + ## ${djangopid} | + ## ${mitx_version} + ##
    ## links which are tied to idash-sections below. ## the links are acativated and handled in instructor_dashboard.coffee ## when the javascript loads, it clicks on idash-default-section -

    [ - Course Info | - Enrollment | - Student Admin | - Data Download | - Analytics - ]

    +

    + % for section_data in sections: + ${ section_data['section_display_name'] } + % endfor +

    ## each section corresponds to a section_data sub-dictionary provided by the view ## to keep this short, sections can be pulled out into their own files -
    - <%include file="course_info.html"/> -
    - - -
    - ${ section_data['enrollment']['placeholder'] } -
    - - -
    - ${ section_data['student_admin']['placeholder'] } -
    - - -
    - - - - - -
    -
    - -
    - -
    -
    + % for section_data in sections: +
    + <%include file="${ section_data['section_key'] }.html" args="section_data=section_data" /> +
    + % endfor
    diff --git a/lms/templates/courseware/instructor_dashboard_2/student_admin.html b/lms/templates/courseware/instructor_dashboard_2/student_admin.html new file mode 100644 index 0000000000..32f169cc02 --- /dev/null +++ b/lms/templates/courseware/instructor_dashboard_2/student_admin.html @@ -0,0 +1,23 @@ +<%page args="section_data"/> + +
    +

    Select student

    + +
    + ## +
    +

    grade

    +

    85 (B)

    +
    + ## + + ## +
    + + +
    diff --git a/lms/urls.py b/lms/urls.py index 119c3dedb6..5ccda65af9 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -255,6 +255,10 @@ if settings.COURSEWARE_ENABLED: 'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard_2"), # api endpoints for instructor + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enroll_unenroll$', + 'instructor.views.api.enroll_unenroll', name="enroll_unenroll"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/access_allow_revoke$', + 'instructor.views.api.access_allow_revoke', name="access_allow_revoke"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/grading_config$', 'instructor.views.api.grading_config', name="grading_config"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enrolled_students_profiles(?P/csv)?$',