diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py
index ce3419ece2..055598c3d9 100644
--- a/lms/djangoapps/instructor/access.py
+++ b/lms/djangoapps/instructor/access.py
@@ -13,6 +13,14 @@ from django.contrib.auth.models import User, Group
from courseware.access import get_access_group_name
+def list_with_level(course, level):
+ grpname = get_access_group_name(course, level)
+ try:
+ return Group.objects.get(name=grpname).user_set.all()
+ except Group.DoesNotExist:
+ return []
+
+
def allow_access(course, user, level):
"""
Allow user access to course modification.
diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py
index ad195182c1..f76247cb0e 100644
--- a/lms/djangoapps/instructor/enrollment.py
+++ b/lms/djangoapps/instructor/enrollment.py
@@ -5,8 +5,10 @@ Does not include any access control, be sure to check access before calling.
"""
import re
+import json
from django.contrib.auth.models import User
from student.models import CourseEnrollment, CourseEnrollmentAllowed
+from courseware.models import StudentModule
def enroll_emails(course_id, student_emails, auto_enroll=False):
@@ -136,3 +138,52 @@ def split_input_list(str_list):
new_list = [s for s in new_list if s != '']
return new_list
+
+
+def reset_student_attempts(course_id, student, problem_to_reset, delete_module=False):
+ """
+ Reset student attempts for a problem. Optionally deletes all student state for the specified problem.
+
+ In the previous instructor dashboard it was possible to modify/delete
+ modules that were not problems. That has been disabled for safety.
+
+ student is a User
+ problem_to_reset is the name of a problem e.g. 'L2Node1'.
+ To build the module_state_key 'problem/' and course information will be appended to problem_to_reset.
+ """
+ if problem_to_reset[-4:] == ".xml":
+ problem_to_reset = problem_to_reset[:-4]
+
+ problem_to_reset = "problem/" + problem_to_reset
+
+ (org, course_name, _) = course_id.split("/")
+ module_state_key = "i4x://" + org + "/" + course_name + "/" + problem_to_reset
+ module_to_reset = StudentModule.objects.get(student_id=student.id,
+ course_id=course_id,
+ module_state_key=module_state_key)
+
+ if delete_module:
+ module_to_reset.delete()
+ else:
+ _reset_module_attempts(module_to_reset)
+
+
+def _reset_module_attempts(studentmodule):
+ """ Reset the number of attempts on a studentmodule. """
+ # load the state json
+ problem_state = json.loads(studentmodule.state)
+ # old_number_of_attempts = problem_state["attempts"]
+ problem_state["attempts"] = 0
+
+ # save
+ studentmodule.state = json.dumps(problem_state)
+ studentmodule.save()
+ # track.views.server_track(request,
+ # '{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format(
+ # old_attempts=old_number_of_attempts,
+ # student=student_to_reset,
+ # problem=studentmodule.module_state_key,
+ # instructor=request.user,
+ # course=course_id),
+ # {},
+ # page='idashboard')
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index 0da4c715fd..d55928833e 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -10,13 +10,16 @@ TODO a lot of these GETs should be PUTs
import json
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
-from django.http import HttpResponse
+from django.core.urlresolvers import reverse
+from django.http import HttpResponse, HttpResponseBadRequest
from courseware.courses import get_course_with_access
from django.contrib.auth.models import User, Group
+from courseware.models import StudentModule
+import instructor.enrollment as enrollment
from instructor.enrollment import split_input_list, enroll_emails, unenroll_emails
-from instructor.access import allow_access, revoke_access
+from instructor.access import allow_access, revoke_access, list_with_level
import analytics.basic
import analytics.distributions
import analytics.csvs
@@ -27,6 +30,7 @@ import analytics.csvs
def enroll_unenroll(request, course_id):
"""
Enroll or unenroll students by email.
+ Requires staff access.
"""
course = get_course_with_access(request.user, course_id, 'staff', depth=None)
@@ -48,7 +52,8 @@ def enroll_unenroll(request, course_id):
@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)
+ Modify staff/instructor access.
+ Requires instructor access.
Query parameters:
email is the target users email
@@ -71,7 +76,33 @@ def access_allow_revoke(request, course_id):
raise ValueError("unrecognized mode '{}'".format(mode))
response_payload = {
- 'done': 'yup',
+ 'DONE': 'YES',
+ }
+ 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 list_instructors_staff(request, course_id):
+ """
+ List instructors and staff.
+ Requires staff access.
+ """
+ course = get_course_with_access(request.user, course_id, 'staff', depth=None)
+
+ def extract_user(user):
+ return {
+ 'username': user.username,
+ 'email': user.email,
+ 'first_name': user.first_name,
+ 'last_name': user.last_name,
+ }
+
+ response_payload = {
+ 'course_id': course_id,
+ 'instructor': map(extract_user, list_with_level(course, 'instructor')),
+ 'staff': map(extract_user, list_with_level(course, 'staff')),
}
response = HttpResponse(json.dumps(response_payload), content_type="application/json")
return response
@@ -178,3 +209,95 @@ def profile_distribution(request, course_id):
}
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 get_student_progress_url(request, course_id):
+ """
+ Get the progress url of a student.
+ Limited to staff access.
+
+ Takes query paremeter student_email and if the student exists
+ returns e.g. {
+ 'progress_url': '/../...'
+ }
+ """
+ course = get_course_with_access(request.user, course_id, 'staff', depth=None)
+
+ student_email = request.GET.get('student_email')
+ if not student_email:
+ # TODO Is there a way to do a - say - 'raise Http400'?
+ return HttpResponseBadRequest()
+ user = User.objects.get(email=student_email)
+
+ progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id})
+
+ response_payload = {
+ 'course_id': course_id,
+ 'progress_url': progress_url,
+ }
+ 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 redirect_to_student_progress(request, course_id):
+ """
+ Redirects to the specified students progress page
+ Limited to staff access.
+
+ Takes query parameter student_email
+ """
+ course = get_course_with_access(request.user, course_id, 'staff', depth=None)
+
+ student_email = request.GET.get('student_email')
+ if not student_email:
+ # TODO Is there a way to do a - say - 'raise Http400'?
+ return HttpResponseBadRequest()
+ user = User.objects.get(email=student_email)
+
+ progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id})
+
+ response_payload = {
+ 'course_id': course_id,
+ 'progress_url': progress_url,
+ }
+ 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 reset_student_attempts(request, course_id):
+ """
+ Resets a students attempts counter. Optionally deletes student state for a problem.
+ Limited to staff access.
+
+ Takes query parameter student_email
+ Takes query parameter problem_to_reset
+ Takes query parameter delete_module
+ """
+ course = get_course_with_access(request.user, course_id, 'staff', depth=None)
+
+ student_email = request.GET.get('student_email')
+ problem_to_reset = request.GET.get('problem_to_reset')
+ will_delete_module = {'true': True}.get(request.GET.get('delete_module', ''), False)
+
+ if not student_email or not problem_to_reset:
+ return HttpResponseBadRequest()
+
+ user = User.objects.get(email=student_email)
+
+ try:
+ enrollment.reset_student_attempts(course_id, user, problem_to_reset, delete_module=will_delete_module)
+ except StudentModule.DoesNotExist:
+ return HttpResponseBadRequest()
+
+ response_payload = {
+ 'course_id': course_id,
+ 'delete_module': will_delete_module,
+ }
+ 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
index faa5ebcef3..aea62813db 100644
--- a/lms/djangoapps/instructor/views/instructor_dashboard.py
+++ b/lms/djangoapps/instructor/views/instructor_dashboard.py
@@ -106,6 +106,8 @@ def _section_membership(course_id):
'section_display_name': 'Membership',
'enroll_button_url': reverse('enroll_unenroll', kwargs={'course_id': course_id}),
'unenroll_button_url': reverse('enroll_unenroll', kwargs={'course_id': course_id}),
+ 'list_instructors_staff_url': reverse('list_instructors_staff', kwargs={'course_id': course_id}),
+ 'access_allow_revoke_url': reverse('access_allow_revoke', kwargs={'course_id': course_id}),
}
return section_data
@@ -115,6 +117,9 @@ def _section_student_admin(course_id):
section_data = {
'section_key': 'student_admin',
'section_display_name': 'Student Admin',
+ 'get_student_progress_url': reverse('get_student_progress_url', kwargs={'course_id': course_id}),
+ 'unenroll_button_url': reverse('enroll_unenroll', kwargs={'course_id': course_id}),
+ 'reset_student_attempts_url': reverse('reset_student_attempts', 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
deleted file mode 100644
index 00cf09bc4c..0000000000
--- a/lms/static/coffee/src/instructor_dashboard.coffee
+++ /dev/null
@@ -1,281 +0,0 @@
-# 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_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.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_membership idash_content.find(".#{CSS_IDASH_SECTION}#membership")
- setup_section_analytics idash_content.find(".#{CSS_IDASH_SECTION}#analytics")
-
-
-# setup the data download section
-setup_section_membership = (section) ->
- log "setting up instructor dashboard section - membership"
-
- 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"
- url = $(this).data('endpoint')
- if $(this).data 'csv'
- url += '/csv'
- location.href = url
- else
- reset_display()
- $.getJSON url, (data) ->
- # setup SlickGrid
- options =
- enableCellNavigation: true
- enableColumnReorder: false
-
- columns = ({id: feature, field: feature, name: feature} for feature in data.queried_features)
- grid_data = data.students
-
- 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) ->
- 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"
-
- for feature in data.available_features
- opt = $ '',
- text: data.display_names[feature]
- data:
- feature: feature
-
- distribution_select.append opt
-
- distribution_select.change ->
- 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]}
- if feature_res.error
- console.warn(feature_res.error)
- display_text.text 'Error fetching data'
- else
- if feature_res.type is 'EASY_CHOICE'
- # 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.text 'Unavailable Metric\n' + JSON.stringify(feature_res)
diff --git a/lms/static/coffee/src/instructor_dashboard/analytics.coffee b/lms/static/coffee/src/instructor_dashboard/analytics.coffee
new file mode 100644
index 0000000000..0a9b9b0287
--- /dev/null
+++ b/lms/static/coffee/src/instructor_dashboard/analytics.coffee
@@ -0,0 +1,113 @@
+log = -> console.log.apply console, arguments
+plantTimeout = (ms, cb) -> setTimeout cb, ms
+
+
+class Analytics
+ constructor: (@$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')
+ @$distribution_select = @$section.find('select#distributions')
+
+ @populate_selector => @$distribution_select.change => @on_selector_change()
+
+
+ reset_display: ->
+ @$display_text.empty()
+ @$display_graph.empty()
+ @$display_table.empty()
+
+
+ populate_selector: (cb) ->
+ @get_profile_distributions [], (data) =>
+ @$distribution_select.find('option').eq(0).text "-- Select distribution"
+
+ for feature in data.available_features
+ opt = $ '',
+ text: data.display_names[feature]
+ data:
+ feature: feature
+
+ @$distribution_select.append opt
+
+ cb?()
+
+
+ on_selector_change: ->
+ # log 'changeargs', arguments
+ opt = @$distribution_select.children('option:selected')
+ feature = opt.data 'feature'
+ log "distribution selected: #{feature}"
+
+ @reset_display()
+ return unless feature
+ @get_profile_distributions [feature], (data) =>
+ feature_res = data.feature_results[feature]
+ # feature response format: {'error': 'optional error string', 'type': 'SOME_TYPE', 'data': [stuff]}
+ if feature_res.error
+ console.warn(feature_res.error)
+ @$display_text.text 'Error fetching data'
+ else
+ if feature_res.type is 'EASY_CHOICE'
+ # 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
+
+ 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]
+
+ $.plot graph_placeholder, [
+ data: graph_data
+ ]
+ else
+ console.warn("don't know how to show #{feature_res.type}")
+ @$display_text.text 'Unavailable Metric\n' + JSON.stringify(feature_res)
+
+
+ # handler can be either a callback for success or a mapping e.g. {success: ->, error: ->, complete: ->}
+ get_profile_distributions: (featurelist, handler) ->
+ settings =
+ dataType: 'json'
+ url: @$distribution_select.data 'endpoint'
+ data: features: JSON.stringify featurelist
+
+ if typeof handler is 'function'
+ _.extend settings, success: handler
+ else
+ _.extend settings, handler
+
+ $.ajax settings
+
+
+# exports
+_.defaults window, InstructorDashboard: {}
+_.defaults window.InstructorDashboard, sections: {}
+_.defaults window.InstructorDashboard.sections,
+ Analytics: Analytics
diff --git a/lms/static/coffee/src/instructor_dashboard/data_download.coffee b/lms/static/coffee/src/instructor_dashboard/data_download.coffee
new file mode 100644
index 0000000000..a321b00cf4
--- /dev/null
+++ b/lms/static/coffee/src/instructor_dashboard/data_download.coffee
@@ -0,0 +1,54 @@
+log = -> console.log.apply console, arguments
+plantTimeout = (ms, cb) -> setTimeout cb, ms
+
+
+class DataDownload
+ constructor: (@$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')
+
+ $list_studs_btn = @$section.find("input[name='list-profiles']'")
+ $list_studs_btn.click (e) =>
+ log "fetching student list"
+ url = $list_studs_btn.data('endpoint')
+ if $(e.target).data 'csv'
+ url += '/csv'
+ location.href = url
+ else
+ @reset_display()
+ $.getJSON url, (data) =>
+ # setup SlickGrid
+ options =
+ enableCellNavigation: true
+ enableColumnReorder: false
+
+ columns = ({id: feature, field: feature, name: feature} for feature in data.queried_features)
+ grid_data = data.students
+
+ $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 = $grade_config_btn.data('endpoint')
+ $.getJSON url, (data) =>
+ @reset_display()
+ @$display_text.html data['grading_config_summary']
+
+
+ reset_display: ->
+ @$display_text.empty()
+ @$display_table.empty()
+
+
+# exports
+_.defaults window, InstructorDashboard: {}
+_.defaults window.InstructorDashboard, sections: {}
+_.defaults window.InstructorDashboard.sections,
+ DataDownload: DataDownload
diff --git a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee
new file mode 100644
index 0000000000..b2039b7648
--- /dev/null
+++ b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee
@@ -0,0 +1,69 @@
+# Instructor Dashboard Tab Manager
+
+log = -> console.log.apply console, arguments
+plantTimeout = (ms, cb) -> setTimeout cb, ms
+
+
+CSS_INSTRUCTOR_CONTENT = 'instructor-dashboard-content-2'
+CSS_ACTIVE_SECTION = 'active-section'
+CSS_IDASH_SECTION = 'idash-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}"
+
+ section.data('wrapper')?.onClickTitle?()
+
+ # 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.eq(0).click()
+
+
+# call setup handlers for each section
+setup_instructor_dashboard_sections = (idash_content) ->
+ log "setting up instructor dashboard sections"
+ # fault isolation
+ # an error thrown in one section will not block other sections from exectuing
+ plantTimeout 0, -> new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download"
+ plantTimeout 0, -> new window.InstructorDashboard.sections.Membership idash_content.find ".#{CSS_IDASH_SECTION}#membership"
+ plantTimeout 0, -> new window.InstructorDashboard.sections.StudentAdmin idash_content.find ".#{CSS_IDASH_SECTION}#student_admin"
+ plantTimeout 0, -> new window.InstructorDashboard.sections.Analytics idash_content.find ".#{CSS_IDASH_SECTION}#analytics"
diff --git a/lms/static/coffee/src/instructor_dashboard/membership.coffee b/lms/static/coffee/src/instructor_dashboard/membership.coffee
new file mode 100644
index 0000000000..fd6f01737b
--- /dev/null
+++ b/lms/static/coffee/src/instructor_dashboard/membership.coffee
@@ -0,0 +1,183 @@
+log = -> console.log.apply console, arguments
+plantTimeout = (ms, cb) -> setTimeout cb, ms
+
+
+class BatchEnrollment
+ constructor: (@$container) ->
+ log "setting up instructor dashboard subsection - batch enrollment"
+
+ $emails_input = @$container.find("textarea[name='student-emails']'")
+ $btn_enroll = @$container.find("input[name='enroll']'")
+ $btn_unenroll = @$container.find("input[name='unenroll']'")
+ $task_response = @$container.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 ->
+ log 'VAL', $emails_input.val()
+ $.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()
+
+
+# manages a list of instructors or staff and the control of their access.
+class AuthorityList
+ # level is in ['instructor', 'staff']
+ constructor: (@$container, @level) ->
+ log 'setting up instructor dashboard subsection - authlist management for #{@level}'
+
+ @$display_table = @$container.find('.auth-list-table')
+ $add_section = @$container.find('.auth-list-add')
+ $allow_field = $add_section.find("input[name='email']")
+ $allow_button = $add_section.find("input[name='allow']")
+ @list_endpoint = @$display_table.data 'endpoint'
+ @access_change_endpoint = $add_section.data 'endpoint'
+
+ $allow_button.click =>
+ @access_change($allow_field.val(), @level, 'allow', @reload_auth_list)
+ $allow_field.val ''
+
+ @reload_auth_list()
+
+ reload_auth_list: =>
+ $.getJSON @list_endpoint, (data) =>
+ log data
+
+ @$display_table.empty()
+
+ options =
+ enableCellNavigation: true
+ enableColumnReorder: false
+
+ columns = [
+ id: 'username'
+ field: 'username'
+ name: 'Username'
+ ,
+ id: 'email'
+ field: 'email'
+ name: 'Email'
+ ,
+ id: 'revoke'
+ field: 'revoke'
+ name: 'Revoke'
+ formatter: (row, cell, value, columnDef, dataContext) ->
+ "Revoke Access"
+ ]
+
+ table_data = data[@level]
+ log 'table_data', table_data
+
+ $table_placeholder = $ '', class: 'slickgrid'
+ @$display_table.append $table_placeholder
+ log '@$display_table', $table_placeholder
+ grid = new Slick.Grid($table_placeholder, table_data, columns, options)
+ grid.autosizeColumns()
+
+ grid.onClick.subscribe (e, args) =>
+ item = args.grid.getDataItem(args.row)
+ if args.cell is 2
+ @access_change(item.email, @level, 'revoke', @reload_auth_list)
+
+ access_change: (email, level, mode, cb) ->
+ url = @access_change_endpoint
+ $.getJSON @access_change_endpoint, {email: email, level: @level, mode: mode}, (data) ->
+ log data
+ cb?()
+
+
+class Membership
+ constructor: (@$section) ->
+ log "setting up instructor dashboard section - membership"
+ @$section.data 'wrapper', @
+
+ # isolate sections from each other's errors.
+ plantTimeout 0, => @batchenrollment = new BatchEnrollment @$section.find '.batch-enrollment'
+ plantTimeout 0, => @stafflist = new AuthorityList (@$section.find '.auth-list-container.auth-list-staff'), 'staff'
+ plantTimeout 0, => @instructorlist = new AuthorityList (@$section.find '.auth-list-container.auth-list-instructor'), 'instructor'
+
+ onClickTitle: ->
+ @stafflist.$display_table.empty()
+ @stafflist.reload_auth_list()
+ @instructorlist.$display_table.empty()
+ @instructorlist.reload_auth_list()
+
+
+# exports
+_.defaults window, InstructorDashboard: {}
+_.defaults window.InstructorDashboard, sections: {}
+_.defaults window.InstructorDashboard.sections,
+ Membership: Membership
diff --git a/lms/static/coffee/src/instructor_dashboard/student_admin.coffee b/lms/static/coffee/src/instructor_dashboard/student_admin.coffee
new file mode 100644
index 0000000000..dbc95ca200
--- /dev/null
+++ b/lms/static/coffee/src/instructor_dashboard/student_admin.coffee
@@ -0,0 +1,83 @@
+log = -> console.log.apply console, arguments
+plantTimeout = (ms, cb) -> setTimeout cb, ms
+
+
+class StudentAdmin
+ constructor: (@$section) ->
+ log "setting up instructor dashboard section - student admin"
+
+ @$student_email_field = @$section.find("input[name='student-select']")
+ @$student_progress_link = @$section.find('a.progress-link')
+ @$unenroll_btn = @$section.find("input[name='unenroll']")
+ @$problem_select_field = @$section.find("input[name='problem-select']")
+ @$reset_attempts_btn = @$section.find("input[name='reset-attempts']")
+ @$delete_states_btn = @$section.find("input[name='delete-state']")
+
+ @$student_progress_link.click (e) =>
+ e.preventDefault()
+ email = @$student_email_field.val()
+ @get_student_progress_link email,
+ success: (data) ->
+ log 'redirecting...'
+ window.location = data.progress_url
+ error: ->
+ console.warn 'error getting student progress url for ' + email
+
+ @$unenroll_btn.click =>
+ $.getJSON @$unenroll_btn.data('endpoint'), unenroll: @$student_email_field.val(), (data) ->
+ log 'data'
+
+ @$reset_attempts_btn.click =>
+ email = @$student_email_field.val()
+ problem_to_reset = @$problem_select_field.val()
+ @reset_student_progress email, problem_to_reset, false,
+ success: -> log 'problem attempts reset!'
+ error: -> console.warn 'error resetting problem state'
+
+ @$delete_states_btn.click =>
+ email = @$student_email_field.val()
+ problem_to_reset = @$problem_select_field.val()
+ @reset_student_progress email, problem_to_reset, true,
+ success: -> log 'problem state deleted!'
+ error: -> console.warn 'error deleting problem state'
+
+
+ # handler can be either a callback for success or a mapping e.g. {success: ->, error: ->, complete: ->}
+ get_student_progress_link: (student_email, handler) ->
+ settings =
+ dataType: 'json'
+ url: @$student_progress_link.data 'endpoint'
+ data: student_email: student_email
+
+ if typeof handler is 'function'
+ _.extend settings, success: handler
+ else
+ _.extend settings, handler
+
+ $.ajax settings
+
+
+ # handler can be either a callback for success or a mapping e.g. {success: ->, error: ->, complete: ->}
+ reset_student_progress: (student_email, problem_to_reset, delete_module, handler) ->
+ settings =
+ dataType: 'json'
+ url: @$reset_attempts_btn.data 'endpoint'
+ data:
+ student_email: student_email
+ problem_to_reset: problem_to_reset
+ delete_module: delete_module
+
+ if typeof handler is 'function'
+ _.extend settings, success: handler
+ else
+ _.extend settings, handler
+
+ $.ajax settings
+
+
+
+# exports
+_.defaults window, InstructorDashboard: {}
+_.defaults window.InstructorDashboard, sections: {}
+_.defaults window.InstructorDashboard.sections,
+ StudentAdmin: StudentAdmin
diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss
index 6f5688e166..f02ada1355 100644
--- a/lms/static/sass/course/instructor/_instructor_2.scss
+++ b/lms/static/sass/course/instructor/_instructor_2.scss
@@ -77,42 +77,62 @@
.instructor-dashboard-wrapper-2 section.idash-section#membership {
- div {
- margin-top: 2em;
+ .vert-left {
+ float: left;
+ width: 45%;
}
- textarea {
- height: 100px;
- width: 500px;
- }
+ .vert-right {
+ float: right;
+ width: 45%;
- .task-res-section {
- h3 {
- color: #646464;
+ .auth-list-container {
+ margin-bottom: 1.5em;
+
+ .auth-list-table {
+ .slickgrid {
+ height: 250px;
+ }
+ }
+
+ .auth-list-add {
+ margin-top: 0.5em;
+ }
}
- ul {
- padding: 0;
- margin: 0;
- margin-top: 0.5em;
- line-height: 1.5em;
- list-style-type: none;
- li {
+ }
+
+ .batch-enrollment {
+ textarea {
+ height: 100px;
+ width: 500px;
+ }
+
+ .task-res-section {
+ margin-top: 1.5em;
+
+ 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#student_admin {
- .h-row {
- margin-bottom: 1em;
- clear: both;
- }
-
- p input select {
- float: left;
- }
+.instructor-dashboard-wrapper-2 section.idash-section#student_admin > {
+ h3 { margin-top: 2em; }
+ input { margin-top: 2em; }
+ a { margin-top: 2em; }
}
diff --git a/lms/templates/courseware/instructor_dashboard_2/membership.html b/lms/templates/courseware/instructor_dashboard_2/membership.html
index d31e2ea10b..37c4d2a2d6 100644
--- a/lms/templates/courseware/instructor_dashboard_2/membership.html
+++ b/lms/templates/courseware/instructor_dashboard_2/membership.html
@@ -1,10 +1,31 @@
<%page args="section_data"/>
-
+
+
+
+
+
Staff Management
+
+
+
+
+
+
+
+
+
Instructor Management
+
+
+
+
+
+
+
diff --git a/lms/templates/courseware/instructor_dashboard_2/student_admin.html b/lms/templates/courseware/instructor_dashboard_2/student_admin.html
index 32f169cc02..9081bcbd71 100644
--- a/lms/templates/courseware/instructor_dashboard_2/student_admin.html
+++ b/lms/templates/courseware/instructor_dashboard_2/student_admin.html
@@ -1,23 +1,16 @@
<%page args="section_data"/>
-
- ##
-
- ##
-
- ##
-
-
-
-
+
Select student
+
+
+##
grade
+##
85 (B)
+
Student Progress Page
+
+
+##
+
+
+
diff --git a/lms/urls.py b/lms/urls.py
index 5ccda65af9..d42e8628f3 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -257,6 +257,8 @@ if settings.COURSEWARE_ENABLED:
# 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/list_instructors_staff$',
+ 'instructor.views.api.list_instructors_staff', name="list_instructors_staff"),
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$',
@@ -265,7 +267,10 @@ if settings.COURSEWARE_ENABLED:
'instructor.views.api.enrolled_students_profiles', name="enrolled_students_profiles"),
url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/profile_distribution$',
'instructor.views.api.profile_distribution', name="profile_distribution"),
-
+ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/get_student_progress_url$',
+ 'instructor.views.api.get_student_progress_url', name="get_student_progress_url"),
+ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/reset_student_attempts$',
+ 'instructor.views.api.reset_student_attempts', name="reset_student_attempts"),
url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$',
'instructor.views.legacy.gradebook', name='gradebook'),