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