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 = $ '