From 5cdf3fb86a9d6d125e63e48f26dec60fd4def393 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 17 Jun 2013 13:26:04 -0400 Subject: [PATCH] add staff management subsection, add student_admin subsection, refactor sections --- lms/djangoapps/instructor/access.py | 8 + lms/djangoapps/instructor/enrollment.py | 51 ++++ lms/djangoapps/instructor/views/api.py | 131 +++++++- .../instructor/views/instructor_dashboard.py | 5 + .../coffee/src/instructor_dashboard.coffee | 281 ------------------ .../src/instructor_dashboard/analytics.coffee | 113 +++++++ .../instructor_dashboard/data_download.coffee | 54 ++++ .../instructor_dashboard.coffee | 69 +++++ .../instructor_dashboard/membership.coffee | 183 ++++++++++++ .../instructor_dashboard/student_admin.coffee | 83 ++++++ .../sass/course/instructor/_instructor_2.scss | 70 +++-- .../instructor_dashboard_2/membership.html | 25 +- .../instructor_dashboard_2/student_admin.html | 35 +-- lms/urls.py | 7 +- 14 files changed, 781 insertions(+), 334 deletions(-) delete mode 100644 lms/static/coffee/src/instructor_dashboard.coffee create mode 100644 lms/static/coffee/src/instructor_dashboard/analytics.coffee create mode 100644 lms/static/coffee/src/instructor_dashboard/data_download.coffee create mode 100644 lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee create mode 100644 lms/static/coffee/src/instructor_dashboard/membership.coffee create mode 100644 lms/static/coffee/src/instructor_dashboard/student_admin.coffee 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 = $ '