From f9fbef542fd984cc841261325d79e870149b0f97 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Fri, 21 Jun 2013 10:39:16 -0400 Subject: [PATCH 01/92] add fake 600x grades script --- .../commands/insert_fake_600x_grades.py | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 common/djangoapps/student/management/commands/insert_fake_600x_grades.py diff --git a/common/djangoapps/student/management/commands/insert_fake_600x_grades.py b/common/djangoapps/student/management/commands/insert_fake_600x_grades.py new file mode 100644 index 000000000000..f62f264174d3 --- /dev/null +++ b/common/djangoapps/student/management/commands/insert_fake_600x_grades.py @@ -0,0 +1,268 @@ +# creates users named johndoen with emails of jdn@edx.org +# they are enrolled in 600x and have fake grades with + +from optparse import make_option +import json +from datetime import datetime + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from django.contrib.auth.models import User +from student.models import UserProfile, CourseEnrollment +from courseware.models import StudentModule + + +class Command(BaseCommand): + + args = '<>' + help = """ + Add fake students and grades to db. + """ + + # option_list = BaseCommand.option_list + ( + # make_option('--course_id', + # action='store', + # dest='course_id', + # help='Specify a particular course.'), + # make_option('--exam_series_code', + # action='store', + # dest='exam_series_code', + # default=None, + # help='Specify a particular exam, using the Pearson code'), + # make_option('--accommodation_pending', + # action='store_true', + # dest='accommodation_pending', + # default=False, + # ), + # ) + + @transaction.autocommit + def _create_jd(self, seed_num, grade): + print (20 * "\n") + ("creating johndoe%s" % seed_num) + (20 * "\n") + + course_id = 'MITx/6.002x/2013_Spring' + email = 'jd%s@edx.org' % seed_num + + user = User(username='johndoe%s' % seed_num, email='jd%s@edx.org' % seed_num, password='1234') + user.save() + + profile = UserProfile(user=User.objects.filter(email='jd%s@edx.org' % seed_num)[0], name='John Doe %s' % seed_num) + profile.save() + + enrollment = CourseEnrollment(user=user, course_id=course_id) + enrollment.save() + + for state_key in MODULE_STATE_KEYS: + # WARN: state and grade/max_grade are completely made up + smod = StudentModule( + module_type='problem', + module_state_key=state_key, + student=user, + course_id=course_id, + state={}, + grade=grade, + max_grade=100, + done='na') + smod.save() + + def _create_n_jds(self, number_to_add, grade): + print "adding fake users" + + seed_num = 1 + while number_to_add > 0: + if len(User.objects.filter(username='johndoe%s' % seed_num)) > 0: + seed_num += 1 + else: + self._create_jd(seed_num, grade) + number_to_add -= 1 + + def _delete_all_jds(self): + [user.delete() for user in User.objects.filter(username__contains="johndoe")] + + def handle(self, *args, **options): + self._delete_all_jds() + res = 50 + for grade_sub in range(res + 1): + self._create_n_jds(1, grade_sub * 100 / res) + + + +# set([sm.module_state_key for sm in SM.objects.filter(module_type='problem')]) +MODULE_STATE_KEYS = set([ + u'i4x://MITx/6.002x/problem/S14E4_Fall_Time', + u'i4x://MITx/6.002x/problem/S20E3_LCR_voltage_divider', + u'i4x://MITx/6.002x/problem/First-order_Transients', + u'i4x://MITx/6.002x/problem/H5P2_Source_Follower_Large_Signal', + u'i4x://MITx/6.002x/problem/S3E3_Circuit_Variables_are_Superpositions_of_values_due_to_each_source_separately', + u'i4x://MITx/6.002x/problem/S2E1_Circuit_Topology', + u'i4x://MITx/6.002x/problem/Resonance', + u'i4x://MITx/6.002x/problem/S5E2_Switch_Model', + u'i4x://MITx/6.002x/problem/S11E1_Small-Signal_MOSFET_Model', + u'i4x://MITx/6.002x/problem/S8E0_Dependent_Source', + u'i4x://MITx/6.002x/problem/H2P3_Logic_Circuits', + u'i4x://MITx/6.002x/problem/Second-order_Circuits', + u'i4x://MITx/6.002x/problem/S12E4_First-Order_Capacitor_Examples', + u'i4x://MITx/6.002x/problem/S14E1_Response_to_step_down', + u'i4x://MITx/6.002x/problem/H10P3_An_L_Network', + u'i4x://MITx/6.002x/problem/S17E1_Particular_Solution', + u'i4x://MITx/6.002x/problem/S7E2_Graphs', + u'i4x://MITx/6.002x/problem/H11P2_Scope_Probe', + u'i4x://MITx/6.002x/problem/S12E5_Neon_Relaxation_Oscillator', + u'i4x://MITx/6.002x/problem/S16E1_Charging_and_Discharging', + u'i4x://MITx/6.002x/problem/H9P1_Response_to_a_Delayed_Impulse', + u'i4x://MITx/6.002x/problem/S1E1_Various_V-I_characteristics', + u'i4x://MITx/6.002x/problem/Impedance_Frequency_Response', + u'i4x://MITx/6.002x/problem/S13E4_First-Order_Inductor_Examples', + u'i4x://MITx/6.002x/problem/S10E2_two_terminal_connection', + u'i4x://MITx/6.002x/problem/Propagation_Delay', + u'i4x://MITx/6.002x/problem/H2P2_Solar_Power', + u'i4x://MITx/6.002x/problem/H3P4_Diode_Limiter', + u'i4x://MITx/6.002x/problem/S7E1_Linearization', + u'i4x://MITx/6.002x/problem/S18E2_Homogenous_Equation_Solution', + u'i4x://MITx/6.002x/problem/S2E2_Associated_Reference_Directions', + u'i4x://MITx/6.002x/problem/S22E1_Which_output_', + u'i4x://MITx/6.002x/problem/H12P1_Current_Source', + u'i4x://MITx/6.002x/problem/S6E0_Thevenin_isolates_nonlinear_element', + u'i4x://MITx/6.002x/problem/S24E4_Generalization_to_impedances', + u'i4x://MITx/6.002x/problem/S16E2_Time_to_Decay', + u'i4x://MITx/6.002x/problem/S1E2_Power_copy', + u'i4x://MITx/6.002x/problem/Curve_Tracer', + u'i4x://MITx/6.002x/problem/H9P3_Designing_a_Shock_Absorber', + u'i4x://MITx/6.002x/problem/H4P2_Zener_Regulator', + u'i4x://MITx/6.002x/problem/S1E1.5_Simple_Power', + u'i4x://MITx/6.002x/problem/S26E1_Power_and_Energy_Review', + u'i4x://MITx/6.002x/problem/S6E3_Piecewise_Linear', + u'i4x://MITx/6.002x/problem/S1E6_KVL', + u'i4x://MITx/6.002x/problem/S15E5_Initial_Conditions', + u'i4x://MITx/6.002x/problem/Capacitors_and_Energy_Storage', + u'i4x://MITx/6.002x/problem/H3P3_Solar_Cell', + u'i4x://MITx/6.002x/problem/H11P1_LC_Tank', + u'i4x://MITx/6.002x/problem/Logic_Gate_Implementation', + u'i4x://MITx/6.002x/problem/S14E2_Rise_Time', + u'i4x://MITx/6.002x/problem/S12E3_Norton_Capacitor_Circuit', + u'i4x://MITx/6.002x/problem/S4E2_Boolean_Functions', + u'i4x://MITx/6.002x/problem/H10P2_New_Impedances', + u'i4x://MITx/6.002x/problem/S24E1_Summing_Amplifier', + u'i4x://MITx/6.002x/problem/S9E1_MOSFET_model', + u'i4x://MITx/6.002x/problem/S17E4_An_LC_circuit', + u'i4x://MITx/6.002x/problem/S2E6_Modeling', + u'i4x://MITx/6.002x/problem/S12E1_Scaling_Capacitors', + u'i4x://MITx/6.002x/problem/H8P1_Impulse', + u'i4x://MITx/6.002x/problem/S1E5_KVL-0', + u'i4x://MITx/6.002x/problem/S9E3_MOSFET_Amplifier_2', + u'i4x://MITx/6.002x/problem/S23E2_Inverting_Amplifier_analysis', + u'i4x://MITx/6.002x/problem/S3E4_Simple_Thevenin', + u'i4x://MITx/6.002x/problem/Q6Final2012', + u'i4x://MITx/6.002x/problem/S15E1_Review_A_Step_Up', + u'i4x://MITx/6.002x/problem/S17E3_Matching_Initial_Conditions', + u'i4x://MITx/6.002x/problem/H1P2_KCL-KVL_vs_Node_Method', + u'i4x://MITx/6.002x/problem/Lab_0_Using_the_Tools', + u'i4x://MITx/6.002x/problem/S21E3_Thevenin_Tank', + u'i4x://MITx/6.002x/problem/S14E3_Fall_Time_Constant', + u'i4x://MITx/6.002x/problem/Q2Final2012', + u'i4x://MITx/6.002x/problem/ex_practice_limited_checks', + u'i4x://MITx/6.002x/problem/H7P2_Time_Constants', + u'i4x://MITx/6.002x/problem/S15E3_Review_A_Pulse_is_Step_Up_then_Step_Down', + u'i4x://MITx/6.002x/problem/S1E2_Power', + u'i4x://MITx/6.002x/problem/S19E4_Magnitudes_and_Angles', + u'i4x://MITx/6.002x/problem/S19E1_Trigonometry_Isn_t_So_Bad', + u'i4x://MITx/6.002x/problem/S2E5_Node_Method', + u'i4x://MITx/6.002x/problem/S13E1_Scaling_Inductors', + u'i4x://MITx/6.002x/problem/ex_practice_limited_checks_3', + u'i4x://MITx/6.002x/problem/S3E6_Norton_Model', + u'i4x://MITx/6.002x/problem/MTQ6', + u'i4x://MITx/6.002x/problem/H12P2_Linear_Regulator', + u'i4x://MITx/6.002x/problem/S1E8_KCL', + u'i4x://MITx/6.002x/problem/Sample_Algebraic_Problem', + u'i4x://MITx/6.002x/problem/S26E2_Energy_Sourced_in_T1', + u'i4x://MITx/6.002x/problem/S13E3_Thevenin_Inductor_Circuit', + u'i4x://MITx/6.002x/problem/H6P2_Phase_Inverter', + u'i4x://MITx/6.002x/problem/H7P1_Series_and_Parallel_Inductors', + u'i4x://MITx/6.002x/problem/S6E2_Load_Line', + u'i4x://MITx/6.002x/problem/H11P3_Branch_Voltages', + u'i4x://MITx/6.002x/problem/S20E1_Inductor_Impedance', + u'i4x://MITx/6.002x/problem/S15E4_Area', + u'i4x://MITx/6.002x/problem/S12E2_Capacitors_Store_Energy', + u'i4x://MITx/6.002x/problem/Sample_Numeric_Problem', + u'i4x://MITx/6.002x/problem/S21E2_LR_filter', + u'i4x://MITx/6.002x/problem/H5P3_Source_Follower_Small_Signal', + u'i4x://MITx/6.002x/problem/S18E1_Particular_Solution', + u'i4x://MITx/6.002x/problem/L2Node0', + u'i4x://MITx/6.002x/problem/L2Node1', + u'i4x://MITx/6.002x/problem/L2Node2', + u'i4x://MITx/6.002x/problem/S1E9_Battery_Model', + u'i4x://MITx/6.002x/problem/S25E2_Relaxation_Oscillator_Frequency', + u'i4x://MITx/6.002x/problem/MTQ3', + u'i4x://MITx/6.002x/problem/MTQ2', + u'i4x://MITx/6.002x/problem/MTQ1', + u'i4x://MITx/6.002x/problem/H6P3_Series_and_Parallel_Capacitors', + u'i4x://MITx/6.002x/problem/MTQ5', + u'i4x://MITx/6.002x/problem/MTQ4', + u'i4x://MITx/6.002x/problem/S10E3_Small_Signal_Amplifier', + u'i4x://MITx/6.002x/problem/H10P1_Magnitude_and_Angle', + u'i4x://MITx/6.002x/problem/Q5Final2012', + u'i4x://MITx/6.002x/problem/Resistor_Divider', + u'i4x://MITx/6.002x/problem/H5P1_Zero-Offset_Amplifier', + u'i4x://MITx/6.002x/problem/Lab2b_Mixing_Two_Signals', + u'i4x://MITx/6.002x/problem/H2P1_Voltage-Divider_Design', + u'i4x://MITx/6.002x/problem/S17E5_An_ILC_circuit', + u'i4x://MITx/6.002x/problem/S24E3_Inverting_Amplifier_Generalized', + u'i4x://MITx/6.002x/problem/S19E3_Complex_Numbers', + u'i4x://MITx/6.002x/problem/H1P3_Poor_Workmanship', + u'i4x://MITx/6.002x/problem/S1E3_AC_power', + u'i4x://MITx/6.002x/problem/S9E2_Amplifier_1', + u'i4x://MITx/6.002x/problem/S2E4_Series_and_Parallel', + u'i4x://MITx/6.002x/problem/S8E2_Dependent_Voltage_Source', + u'i4x://MITx/6.002x/problem/S11E2_Small-Signal_Model_of_Diode-Connected_MOSFET', + u'i4x://MITx/6.002x/problem/H3P1_A_Logic_Family', + u'i4x://MITx/6.002x/problem/S3E1_Node_Equation_Review', + u'i4x://MITx/6.002x/problem/H12P3_Opamps_and_Filter_Design', + u'i4x://MITx/6.002x/problem/H4P1_Vacuum_Diode', + u'i4x://MITx/6.002x/problem/Q4Final2012', + u'i4x://MITx/6.002x/problem/S24E2_Difference_Amplifier', + u'i4x://MITx/6.002x/problem/H9P2_SOC', + u'i4x://MITx/6.002x/problem/S5E1_Logic_with_Switches', + u'i4x://MITx/6.002x/problem/S23E3_L23AmplifierInputResistance', + u'i4x://MITx/6.002x/problem/S25E1_Positive_Feedback_Gain', + u'i4x://MITx/6.002x/problem/Circuit_Sandbox', + u'i4x://MITx/6.002x/problem/S3E2_Circuit_Voltages_and_Currents_are_Linear_Combinations_of_Source_Strengths', + u'i4x://MITx/6.002x/problem/S5E3_SR_Model', + u'i4x://MITx/6.002x/problem/S11E3_Source_Follower_Again_', + u'i4x://MITx/6.002x/problem/S21E4_AM_Radio_Tuning', + u'i4x://MITx/6.002x/problem/Mosfet_Amplifier', + u'i4x://MITx/6.002x/problem/H4P3_Dependent_Source_Circuit', + u'i4x://MITx/6.002x/problem/Q1Final2012', + u'i4x://MITx/6.002x/problem/S20E2_RC_voltage_divider', + u'i4x://MITx/6.002x/problem/S1E7_KCL-0', + u'i4x://MITx/6.002x/problem/H8P2_Physiological_Model', + u'i4x://MITx/6.002x/problem/H8P3_Memory', + u'i4x://MITx/6.002x/problem/ex_practice_2', + u'i4x://MITx/6.002x/problem/ex_practice_3', + u'i4x://MITx/6.002x/problem/H6P1_The_NewFET_device', + u'i4x://MITx/6.002x/problem/S8E1_Dependent_Current_Source', + u'i4x://MITx/6.002x/problem/S18E3_Total_Solution', + u'i4x://MITx/6.002x/problem/H3P2_Graphical_Model_of_Inverter', + u'i4x://MITx/6.002x/problem/S23E1_Non-Inverting_Amplifier', + u'i4x://MITx/6.002x/problem/S22E2_The_filter_is_ringing', + u'i4x://MITx/6.002x/problem/S21E1_Second-order_impedance', + u'i4x://MITx/6.002x/problem/S20E4_LCR_voltage_divider_frequency_limits', + u'i4x://MITx/6.002x/problem/S15E2_Review_A_Step_Down', + u'i4x://MITx/6.002x/problem/S3E5_Thevenin_Model', + u'i4x://MITx/6.002x/problem/S4E3_Truth_Table', + u'i4x://MITx/6.002x/problem/S7E3_Linearization', + u'i4x://MITx/6.002x/problem/S2E3_Using_KVL_KCL_and_VI_constraints', + u'i4x://MITx/6.002x/problem/H7P3_The_Curse_of_Lead_Inductance', + u'i4x://MITx/6.002x/problem/S13E2_Inductors_Store_Energy', + u'i4x://MITx/6.002x/problem/S26E3_A_Hot_Processor', + u'i4x://MITx/6.002x/problem/S1E1.5_Simple_Power_copy', + u'i4x://MITx/6.002x/problem/S6E1_A_Nonlinear_Element', + u'i4x://MITx/6.002x/problem/S19E2_Exponentials_are_Nice', + u'i4x://MITx/6.002x/problem/S10E1_Incremental_Voltage', + u'i4x://MITx/6.002x/problem/Op_Amps', + u'i4x://MITx/6.002x/problem/S24E5_Generalization_to_nonlinear_elements', + u'i4x://MITx/6.002x/problem/Q3Final2012', + u'i4x://MITx/6.002x/problem/S17E2_Characteristic_Equation', + u'i4x://MITx/6.002x/problem/H1P1_Resistor_Combinations', + u'i4x://MITx/6.002x/problem/S1E1_Various_V-I_characteristics_copy']) + From 792bb67f7fba7f8bc6ee82dad70f588ae1f9c897 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Fri, 21 Jun 2013 10:43:41 -0400 Subject: [PATCH 02/92] move instructor views.py to views/legacy.py --- lms/djangoapps/instructor/views/__init__.py | 0 lms/djangoapps/instructor/{views.py => views/legacy.py} | 0 lms/urls.py | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 lms/djangoapps/instructor/views/__init__.py rename lms/djangoapps/instructor/{views.py => views/legacy.py} (100%) diff --git a/lms/djangoapps/instructor/views/__init__.py b/lms/djangoapps/instructor/views/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views/legacy.py similarity index 100% rename from lms/djangoapps/instructor/views.py rename to lms/djangoapps/instructor/views/legacy.py diff --git a/lms/urls.py b/lms/urls.py index f6978f5f7b37..14dd5ee4fd4a 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -264,12 +264,12 @@ # For the instructor url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor$', - 'instructor.views.instructor_dashboard', name="instructor_dashboard"), + 'instructor.views.legacy.instructor_dashboard', name="instructor_dashboard"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$', - 'instructor.views.gradebook', name='gradebook'), + 'instructor.views.legacy.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', - 'instructor.views.grade_summary', name='grade_summary'), + 'instructor.views.legacy.grade_summary', name='grade_summary'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/staff_grading$', 'open_ended_grading.views.staff_grading', name='staff_grading'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/staff_grading/get_next$', From 29bf5d0cab40d045d328d4b4cadaf5e23ae59f60 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 10:12:10 -0400 Subject: [PATCH 03/92] add new blank instructor dashboard 2 --- lms/djangoapps/courseware/tabs.py | 10 ++++ .../instructor/views/instructor_dashboard.py | 47 +++++++++++++++ .../coffee/src/instructor_dashboard.coffee | 29 ++++++++++ lms/static/sass/course.scss.mako | 1 + .../sass/course/instructor/_instructor_2.scss | 24 ++++++++ .../courseware/instructor_dashboard_2.html | 58 +++++++++++++++++++ lms/urls.py | 3 + 7 files changed, 172 insertions(+) create mode 100644 lms/djangoapps/instructor/views/instructor_dashboard.py create mode 100644 lms/static/coffee/src/instructor_dashboard.coffee create mode 100644 lms/static/sass/course/instructor/_instructor_2.scss create mode 100644 lms/templates/courseware/instructor_dashboard_2.html diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 149542c344de..35b894d92d7b 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -290,6 +290,11 @@ def get_course_tabs(user, course, active_page): tabs.append(CourseTab('Instructor', reverse('instructor_dashboard', args=[course.id]), active_page == 'instructor')) + + if has_access(user, course, 'staff'): + tabs.append(CourseTab('Instructor 2', + reverse('instructor_dashboard_2', args=[course.id]), + active_page == 'instructor_2')) return tabs @@ -341,6 +346,11 @@ def get_default_tabs(user, course, active_page): link = reverse('instructor_dashboard', args=[course.id]) tabs.append(CourseTab('Instructor', link, active_page == 'instructor')) + if has_access(user, course, 'staff'): + tabs.append(CourseTab('Instructor 2', + reverse('instructor_dashboard_2', args=[course.id]), + active_page == 'instructor_2')) + return tabs diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py new file mode 100644 index 000000000000..7668c7a3b7ed --- /dev/null +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -0,0 +1,47 @@ +""" +Instructor Views +""" + +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.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 + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +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) + instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists + forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR) + + context = { + 'course': course, + 'staff_access': True, + 'admin_access': request.user.is_staff, + 'instructor_access': instructor_access, + 'forum_admin_access': forum_admin_access, + 'course_errors': modulestore().get_item_errors(course.location), + 'djangopid': os.getpid(), + 'mitx_version': getattr(settings, 'MITX_VERSION_STRING', ''), + 'offline_grade_log': offline_grades_available(course_id), + 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), + } + + return render_to_response('courseware/instructor_dashboard_2.html', context) diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee new file mode 100644 index 000000000000..0751e4534833 --- /dev/null +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -0,0 +1,29 @@ +# Instructor Dashboard Manager +# TODO add deep linking + +log = -> console.log.apply console, arguments + +CSS_INSTRUCTOR_CONTENT = 'instructor-dashboard-content-2' +CSS_ACTIVE_SECTION = 'active-section' +CSS_IDASH_SECTION = 'idash-section' + +$ => + instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}" + if instructor_dashboard_content.length != 0 + setup_instructor_dashboard instructor_dashboard_content + +setup_instructor_dashboard = (idash_content) => + links = idash_content.find('.instructor_nav').find('a') + log 'links', links + for link in ($ link for link in links) + log 'link', link + + link.click -> + log 'link click', link + + idash_content.find(".#{CSS_IDASH_SECTION}").removeClass CSS_ACTIVE_SECTION + section_name = $(this).data 'section' + section = idash_content.find "##{section_name}" + section.addClass CSS_ACTIVE_SECTION + + log section_name diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 9d65505316dd..3aaa70a6c895 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -63,6 +63,7 @@ // instructor @import "course/instructor/instructor"; +@import "course/instructor/instructor_2"; // discussion @import "course/discussion/form-wmd-toolbar"; diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss new file mode 100644 index 000000000000..c7cd02089adc --- /dev/null +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -0,0 +1,24 @@ +.instructor-dashboard-wrapper-2 { + @extend .table-wrapper; + display: table; + + section.instructor-dashboard-content-2 { + @extend .content; + padding: 40px; + width: 100%; + + h1 { + @extend .top-header; + } + + section.idash-section { + // background-color: #0f0; + display: none; + + &.active-section { + // background-color: #ff0; + display: block; + } + } + } +} diff --git a/lms/templates/courseware/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2.html new file mode 100644 index 000000000000..928e5bf3b9f5 --- /dev/null +++ b/lms/templates/courseware/instructor_dashboard_2.html @@ -0,0 +1,58 @@ +<%inherit file="/main.html" /> +<%! from django.core.urlresolvers import reverse %> +<%namespace name='static' file='/static_content.html'/> + +<%block name="headextra"> + <%static:css group='course'/> + + + + + + + + + +<%include file="/courseware/course_navigation.html" args="active_page='instructor_2'" /> + + + + + +
+
+
+ +

Instructor Dashboard

+ +

[ + Course Info | + Enrollment | + Student Admin | + Data Download + ]

+ +
+ ${djangopid} | + ${mitx_version} +
+ +
+ Course info content. +
+ +
+ Enrollment content. +
+ +
+ Student admin content. +
+ +
+ Data download content. +
+ +
+
+
diff --git a/lms/urls.py b/lms/urls.py index 14dd5ee4fd4a..be3c1a550e5a 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -266,6 +266,9 @@ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor$', 'instructor.views.legacy.instructor_dashboard', name="instructor_dashboard"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard$', + 'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard_2"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$', 'instructor.views.legacy.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', From 8a3af8f6a40a9c9c5b508c5f0d27551cb3a46e1a Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 12:08:54 -0400 Subject: [PATCH 04/92] add more info to course info section of instructor dash 2 --- .../instructor/views/instructor_dashboard.py | 38 +++++++++++++++++++ .../sass/course/instructor/_instructor_2.scss | 4 ++ .../courseware/instructor_dashboard_2.html | 29 +++++++++++--- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 7668c7a3b7ed..e27df52331ea 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -31,6 +31,13 @@ def instructor_dashboard_2(request, course_id): instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists 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), + } + context = { 'course': course, 'staff_access': True, @@ -42,6 +49,37 @@ def instructor_dashboard_2(request, course_id): 'mitx_version': getattr(settings, 'MITX_VERSION_STRING', ''), 'offline_grade_log': offline_grades_available(course_id), 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), + 'section_data': section_data } return render_to_response('courseware/instructor_dashboard_2.html', context) + + +def _section_course_info(request, course_id): + course = get_course_with_access(request.user, course_id, 'staff', depth=None) + + section_data = {} + section_data['course_id'] = course_id + section_data['display_name'] = course.display_name + section_data['has_started'] = course.has_started() + section_data['has_ended'] = course.has_ended() + section_data['grade_cutoffs'] = "[" + reduce(lambda memo, (letter, score): "{}: {}, ".format(letter, score) + memo , course.grade_cutoffs.items(), "")[:-2] + "]" + return section_data + + +def _section_enrollment(course_id): + section_data = {} + section_data['placeholder'] = "Enrollment content." + return section_data + + +def _section_student_admin(course_id): + section_data = {} + section_data['placeholder'] = "Student Admin content." + return section_data + + +def _section_data_download(course_id): + section_data = {} + section_data['placeholder'] = "Data Download content." + return section_data diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index c7cd02089adc..fb19765eb05b 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -19,6 +19,10 @@ // background-color: #ff0; display: block; } + + .basic-data { + padding: 6px; + } } } } diff --git a/lms/templates/courseware/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2.html index 928e5bf3b9f5..067dc64cdf89 100644 --- a/lms/templates/courseware/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2.html @@ -26,7 +26,7 @@

Instructor Dashboard

[ - Course Info | + Course Info | Enrollment | Student Admin | Data Download @@ -38,19 +38,38 @@

[
- Course info content. +
+ Course Name: + ${ section_data['course_info']['display_name'] } +
+
+ Course ID: + ${ section_data['course_info']['course_id'] } +
+
+ Started: + ${ section_data['course_info']['has_started'] } +
+
+ Ended: + ${ section_data['course_info']['has_ended'] } +
+
+ Grade Cutoffs: + ${ section_data['course_info']['grade_cutoffs'] } +
- Enrollment content. + ${ section_data['enrollment']['placeholder'] }
- Student admin content. + ${ section_data['student_admin']['placeholder'] }
- Data download content. + ${ section_data['data_download']['placeholder'] }
From be8491d2ffd02d9fdf3164a83a4f3f775c4d1121 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 12:09:51 -0400 Subject: [PATCH 05/92] add default section to instructor dash 2 --- lms/static/coffee/src/instructor_dashboard.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index 0751e4534833..d5449c6949d6 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -6,6 +6,7 @@ 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' $ => instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}" @@ -27,3 +28,5 @@ setup_instructor_dashboard = (idash_content) => section.addClass CSS_ACTIVE_SECTION log section_name + + links.filter(".#{CSS_IDASH_DEFAULT_SECTION}").click() From 2df93213f26db5992573307100f94801adf643b1 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 13:04:33 -0400 Subject: [PATCH 06/92] add error log to instructor dash --- .../instructor/views/instructor_dashboard.py | 10 +++++-- .../sass/course/instructor/_instructor_2.scss | 21 ++++++++++++++ .../courseware/instructor_dashboard_2.html | 29 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index e27df52331ea..b02961a31bfc 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -12,6 +12,7 @@ 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.conf import settings from courseware.access import has_access, get_access_group_name, course_beta_test_group_name @@ -44,10 +45,8 @@ def instructor_dashboard_2(request, course_id): 'admin_access': request.user.is_staff, 'instructor_access': instructor_access, 'forum_admin_access': forum_admin_access, - 'course_errors': modulestore().get_item_errors(course.location), 'djangopid': os.getpid(), 'mitx_version': getattr(settings, 'MITX_VERSION_STRING', ''), - 'offline_grade_log': offline_grades_available(course_id), 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), 'section_data': section_data } @@ -64,6 +63,13 @@ def _section_course_info(request, course_id): section_data['has_started'] = course.has_started() section_data['has_ended'] = course.has_ended() section_data['grade_cutoffs'] = "[" + reduce(lambda memo, (letter, score): "{}: {}, ".format(letter, score) + memo , course.grade_cutoffs.items(), "")[:-2] + "]" + section_data['offline_grades'] = offline_grades_available(course_id) + + try: + section_data['course_errors'] = [(escape(a), escape(b)) for (a,b) in modulestore().get_item_errors(course.location)] + except Exception: + section_data['course_errors'] = [('Error fetching errors', '')] + return section_data diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index fb19765eb05b..f73d2b2030f2 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -26,3 +26,24 @@ } } } + + +.instructor-dashboard-wrapper-2 section.idash-section#course-info { + .error-log { + margin-top: 1em; + + .course-error { + margin-bottom: 1em; + + code { + &.course-error-first { + color: red; + } + + &.course-error-second { + color: black; + } + } + } + } +} diff --git a/lms/templates/courseware/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2.html index 067dc64cdf89..035c09eca6d1 100644 --- a/lms/templates/courseware/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2.html @@ -37,41 +37,70 @@

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

Course Errors:

+ %for error in section_data['course_info']['course_errors']: +
+ ${ error[0] }
+ ${ error[1] } +
+ %endfor +
+ + ##
+ ## Section Dump
+ ## ${ section_data['course_info'] } + ##
+
${ section_data['enrollment']['placeholder'] }
+
${ section_data['student_admin']['placeholder'] }
+
${ section_data['data_download']['placeholder'] }
+ From fbc02df94aac3755df3291e84bf14d84385b84bf Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 13:12:38 -0400 Subject: [PATCH 07/92] add enrollment count to instructor dash, tweak error rendering on instructor dash --- lms/djangoapps/instructor/views/instructor_dashboard.py | 4 +++- lms/static/sass/course/instructor/_instructor_2.scss | 2 +- lms/templates/courseware/instructor_dashboard_2.html | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index b02961a31bfc..7d16e3254e5c 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -21,6 +21,7 @@ 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 @ensure_csrf_cookie @@ -60,13 +61,14 @@ def _section_course_info(request, course_id): section_data = {} section_data['course_id'] = course_id section_data['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() section_data['grade_cutoffs'] = "[" + reduce(lambda memo, (letter, score): "{}: {}, ".format(letter, score) + memo , course.grade_cutoffs.items(), "")[:-2] + "]" section_data['offline_grades'] = offline_grades_available(course_id) try: - section_data['course_errors'] = [(escape(a), escape(b)) for (a,b) in modulestore().get_item_errors(course.location)] + section_data['course_errors'] = [(escape(a), '') for (a,b) in modulestore().get_item_errors(course.location)] except Exception: section_data['course_errors'] = [('Error fetching errors', '')] diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index f73d2b2030f2..6977b1fea74f 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -37,7 +37,7 @@ code { &.course-error-first { - color: red; + color: #111; } &.course-error-second { diff --git a/lms/templates/courseware/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2.html index 035c09eca6d1..83a3e7c9e88d 100644 --- a/lms/templates/courseware/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2.html @@ -54,6 +54,11 @@

[ ${ section_data['course_info']['has_started'] } +
+ Students Enrolled: + ${ section_data['course_info']['enrollment_count'] } +
+
Ended: ${ section_data['course_info']['has_ended'] } From 9f4b16fe92a03bd8b00ae8e5e30014c009ede495 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 13:26:38 -0400 Subject: [PATCH 08/92] refactor dashboard section for course info, add comments --- .../instructor/views/instructor_dashboard.py | 6 +- .../instructor_dashboard_2/course_info.html | 49 ++++++++++++++++ .../instructor_dashboard_2.html | 56 +++---------------- 3 files changed, 61 insertions(+), 50 deletions(-) create mode 100644 lms/templates/courseware/instructor_dashboard_2/course_info.html rename lms/templates/courseware/{ => instructor_dashboard_2}/instructor_dashboard_2.html (62%) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 7d16e3254e5c..7fac37b19298 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -52,10 +52,11 @@ def instructor_dashboard_2(request, course_id): 'section_data': section_data } - return render_to_response('courseware/instructor_dashboard_2.html', context) + return render_to_response('courseware/instructor_dashboard_2/instructor_dashboard_2.html', context) def _section_course_info(request, course_id): + """ Provide data for the corresponding dashboard section """ course = get_course_with_access(request.user, course_id, 'staff', depth=None) section_data = {} @@ -76,18 +77,21 @@ 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." return section_data def _section_student_admin(course_id): + """ Provide data for the corresponding dashboard section """ section_data = {} section_data['placeholder'] = "Student Admin content." return section_data def _section_data_download(course_id): + """ Provide data for the corresponding dashboard section """ section_data = {} section_data['placeholder'] = "Data Download content." return section_data diff --git a/lms/templates/courseware/instructor_dashboard_2/course_info.html b/lms/templates/courseware/instructor_dashboard_2/course_info.html new file mode 100644 index 000000000000..111c96adf953 --- /dev/null +++ b/lms/templates/courseware/instructor_dashboard_2/course_info.html @@ -0,0 +1,49 @@ +
+ Course Name: + ${ section_data['course_info']['display_name'] } +
+ +
+ Course ID: + ${ section_data['course_info']['course_id'] } +
+ +
+ Started: + ${ section_data['course_info']['has_started'] } +
+ +
+ Students Enrolled: + ${ section_data['course_info']['enrollment_count'] } +
+ +
+ Ended: + ${ section_data['course_info']['has_ended'] } +
+ +
+ Grade Cutoffs: + ${ section_data['course_info']['grade_cutoffs'] } +
+ +
+ Offline Grades Available: + ${ section_data['course_info']['offline_grades'] } +
+ +
+

Course Errors:

+ %for error in section_data['course_info']['course_errors']: +
+ ${ error[0] }
+ ${ error[1] } +
+ %endfor +
+ +##
+## Section Dump
+## ${ section_data['course_info'] } +##
diff --git a/lms/templates/courseware/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html similarity index 62% rename from lms/templates/courseware/instructor_dashboard_2.html rename to lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index 83a3e7c9e88d..a25db81d53b8 100644 --- a/lms/templates/courseware/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -25,6 +25,9 @@

Instructor Dashboard

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

[ Course Info | Enrollment | @@ -38,56 +41,11 @@

[

+ ## each section corresponds to a section_data sub-dictionary provided by the view + ## to keep this short, sections can be pulled out into their own files +
-
- Course Name: - ${ section_data['course_info']['display_name'] } -
- -
- Course ID: - ${ section_data['course_info']['course_id'] } -
- -
- Started: - ${ section_data['course_info']['has_started'] } -
- -
- Students Enrolled: - ${ section_data['course_info']['enrollment_count'] } -
- -
- Ended: - ${ section_data['course_info']['has_ended'] } -
- -
- Grade Cutoffs: - ${ section_data['course_info']['grade_cutoffs'] } -
- -
- Offline Grades Available: - ${ section_data['course_info']['offline_grades'] } -
- -
-

Course Errors:

- %for error in section_data['course_info']['course_errors']: -
- ${ error[0] }
- ${ error[1] } -
- %endfor -
- - ##
- ## Section Dump
- ## ${ section_data['course_info'] } - ##
+ <%include file="course_info.html"/>
From 62f0dd2136db6e14fed386872fb8041c57937f05 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 13:58:25 -0400 Subject: [PATCH 09/92] add deep linking to instructor dash, add buttons to data download of instructor dash --- .../instructor/views/instructor_dashboard.py | 1 - .../coffee/src/instructor_dashboard.coffee | 19 +++++++++++++++---- .../instructor_dashboard_2.html | 9 ++++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 7fac37b19298..f5f9db1d3fcf 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -93,5 +93,4 @@ def _section_student_admin(course_id): def _section_data_download(course_id): """ Provide data for the corresponding dashboard section """ section_data = {} - section_data['placeholder'] = "Data Download content." return section_data diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index d5449c6949d6..36deed7a0785 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -1,5 +1,4 @@ # Instructor Dashboard Manager -# TODO add deep linking log = -> console.log.apply console, arguments @@ -8,6 +7,8 @@ CSS_ACTIVE_SECTION = 'active-section' CSS_IDASH_SECTION = 'idash-section' CSS_IDASH_DEFAULT_SECTION = 'idash-default-section' +HASH_LINK_PREFIX = '#viewing-' + $ => instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}" if instructor_dashboard_content.length != 0 @@ -19,7 +20,7 @@ setup_instructor_dashboard = (idash_content) => for link in ($ link for link in links) log 'link', link - link.click -> + link.click (e) -> log 'link click', link idash_content.find(".#{CSS_IDASH_SECTION}").removeClass CSS_ACTIVE_SECTION @@ -27,6 +28,16 @@ setup_instructor_dashboard = (idash_content) => section = idash_content.find "##{section_name}" section.addClass CSS_ACTIVE_SECTION - log section_name + location.hash = "#{HASH_LINK_PREFIX}#{section_name}" - links.filter(".#{CSS_IDASH_DEFAULT_SECTION}").click() + log section_name + e.preventDefault() + + # 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.filter(".#{CSS_IDASH_DEFAULT_SECTION}").click() diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index a25db81d53b8..3bfd43c413f3 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -60,7 +60,14 @@

[
- ${ section_data['data_download']['placeholder'] } + +

+ +

+ +

+ +

From 15f0e3ea9ab17d8e4f316b715b427d150143e739 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 14:04:53 -0400 Subject: [PATCH 10/92] remove prints from instructor dash, move glob info on instructor dash to top right --- lms/static/coffee/src/instructor_dashboard.coffee | 7 +------ lms/static/sass/course/instructor/_instructor_2.scss | 8 ++++++++ .../instructor_dashboard_2/instructor_dashboard_2.html | 10 ++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index 36deed7a0785..a430f69f0568 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -16,13 +16,8 @@ $ => setup_instructor_dashboard = (idash_content) => links = idash_content.find('.instructor_nav').find('a') - log 'links', links for link in ($ link for link in links) - log 'link', link - link.click (e) -> - log 'link click', link - idash_content.find(".#{CSS_IDASH_SECTION}").removeClass CSS_ACTIVE_SECTION section_name = $(this).data 'section' section = idash_content.find "##{section_name}" @@ -30,7 +25,7 @@ setup_instructor_dashboard = (idash_content) => location.hash = "#{HASH_LINK_PREFIX}#{section_name}" - log section_name + log "clicked #{section_name}" e.preventDefault() # click default or go to section specified by hash diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 6977b1fea74f..53e2fd6e7adf 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -6,11 +6,19 @@ @extend .content; padding: 40px; width: 100%; + position: relative; h1 { @extend .top-header; } + .instructor_dash_glob_info { + text-align: right; + position: absolute; + top: 46px; + right: 50px; + } + section.idash-section { // background-color: #0f0; display: none; diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index 3bfd43c413f3..56f597d39a9c 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -24,6 +24,10 @@

Instructor Dashboard

+
+ ${djangopid} | + ${mitx_version} +
## links which are tied to idash-sections below. ## the links are acativated and handled in instructor_dashboard.coffee @@ -35,12 +39,6 @@

[ Data Download ]

-
- ${djangopid} | - ${mitx_version} -
- - ## each section corresponds to a section_data sub-dictionary provided by the view ## to keep this short, sections can be pulled out into their own files From 60d83ebb7e6e85280ceb95cd477f6755e2fa141a Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 14:47:04 -0400 Subject: [PATCH 11/92] add instructor api endpoints, tweak instructor comments, tweak instructor styling --- lms/djangoapps/instructor/views/api.py | 92 +++++++++++++++++++ .../instructor/views/instructor_dashboard.py | 8 +- .../coffee/src/instructor_dashboard.coffee | 2 +- .../sass/course/instructor/_instructor_2.scss | 8 ++ .../instructor_dashboard_2.html | 11 +-- lms/urls.py | 3 + 6 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 lms/djangoapps/instructor/views/api.py diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py new file mode 100644 index 000000000000..198fd2f4cc24 --- /dev/null +++ b/lms/djangoapps/instructor/views/api.py @@ -0,0 +1,92 @@ +""" +Instructor Dashboard API views + +Non-html views which the instructor dashboard requests. + +TODO add tracking +""" + +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.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 +import xmodule.graders as xmgraders + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def grading_config(request, course_id): + course = get_course_with_access(request.user, course_id, 'staff', depth=None) + grading_config_summary = _dump_grading_context(course) + + response_payload = { + 'course_id': course_id, + 'grading_config_summary': grading_config_summary, + } + response = HttpResponse(json.dumps(response_payload)) + return response + # response = HttpResponse(json.dumps(response_payload), mimetype='application/json') + + +def _dump_grading_context(course): + """ + Dump information about course grading context (eg which problems are graded in what assignments) + Very useful for debugging grading_policy.json and policy.json + """ + msg = "-----------------------------------------------------------------------------\n" + msg += "Course grader:\n" + + msg += '%s\n' % course.grader.__class__ + graders = {} + if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader): + msg += '\n' + msg += "Graded sections:\n" + for subgrader, category, weight in course.grader.sections: + msg += " subgrader=%s, type=%s, category=%s, weight=%s\n" % (subgrader.__class__, subgrader.type, category, weight) + subgrader.index = 1 + graders[subgrader.type] = subgrader + msg += "-----------------------------------------------------------------------------\n" + msg += "Listing grading context for course %s\n" % course.id + + gc = course.grading_context + msg += "graded sections:\n" + + msg += '%s\n' % gc['graded_sections'].keys() + for (gs, gsvals) in gc['graded_sections'].items(): + msg += "--> Section %s:\n" % (gs) + for sec in gsvals: + s = sec['section_descriptor'] + format = getattr(s.lms, 'format', None) + aname = '' + if format in graders: + g = graders[format] + aname = '%s %02d' % (g.short_label, g.index) + g.index += 1 + elif s.display_name in graders: + g = graders[s.display_name] + aname = '%s' % g.short_label + notes = '' + if getattr(s, 'score_by_attempt', False): + notes = ', score by attempt!' + msg += " %s (format=%s, Assignment=%s%s)\n" % (s.display_name, format, aname, notes) + msg += "all descriptors:\n" + msg += "length=%d\n" % len(gc['all_descriptors']) + msg = '
%s
' % msg.replace('<','<') + return msg diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index f5f9db1d3fcf..80e33c3cfa1d 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -1,5 +1,7 @@ """ -Instructor Views +Instructor Dashboard Views + +TODO add tracking """ import csv @@ -92,5 +94,7 @@ def _section_student_admin(course_id): def _section_data_download(course_id): """ Provide data for the corresponding dashboard section """ - section_data = {} + section_data = { + 'grading_config_url': reverse('grading_config', 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 a430f69f0568..573ba448afc0 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -1,4 +1,4 @@ -# Instructor Dashboard Manager +# Instructor Dashboard Tab Manager log = -> console.log.apply console, arguments diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 53e2fd6e7adf..cd3b2af08b14 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -55,3 +55,11 @@ } } } + + +.instructor-dashboard-wrapper-2 section.idash-section#data-download { + input { + display: block; + margin-bottom: 1em; + } +} diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index 56f597d39a9c..5ebf4caec1b6 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -59,13 +59,10 @@

[
-

- -

- -

- -

+ + + +
diff --git a/lms/urls.py b/lms/urls.py index be3c1a550e5a..fc1c8bf2461e 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -269,6 +269,9 @@ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard$', 'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard_2"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/grading_config$', + 'instructor.views.api.grading_config', name="grading_config"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$', 'instructor.views.legacy.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', From 43710dfa58368d6c24ec94b245ebbb58d137ccbd Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 15:26:48 -0400 Subject: [PATCH 12/92] add coffeescript for data download section, add comments --- lms/djangoapps/instructor/views/api.py | 19 ++++++++++++- .../coffee/src/instructor_dashboard.coffee | 28 +++++++++++++++++-- .../instructor_dashboard_2.html | 12 ++++---- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 198fd2f4cc24..3198ca20fdb3 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -33,6 +33,11 @@ @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) def grading_config(request, course_id): + """ + Respond with json which contains a html formatted grade summary. + + TODO maybe this shouldn't be html already + """ course = get_course_with_access(request.user, course_id, 'staff', depth=None) grading_config_summary = _dump_grading_context(course) @@ -42,7 +47,19 @@ def grading_config(request, course_id): } response = HttpResponse(json.dumps(response_payload)) return response - # response = HttpResponse(json.dumps(response_payload), mimetype='application/json') + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def enrolled_students_profiles(request, course_id): + """ + Respond with json which contains a summary of all enrolled students profile information. + + TODO respond to csv requests as well + TODO accept requests for different attribute sets + """ + + raise NotImplementedError() def _dump_grading_context(course): diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index 573ba448afc0..6eb432712484 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -6,22 +6,31 @@ 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 = '#viewing-' +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 - setup_instructor_dashboard instructor_dashboard_content + 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('.instructor_nav').find('a') + links = idash_content.find(".#{CSS_INSTRUCTOR_NAV}").find('a') for link in ($ link for link in links) link.click (e) -> idash_content.find(".#{CSS_IDASH_SECTION}").removeClass CSS_ACTIVE_SECTION + idash_content.find(".#{CSS_INSTRUCTOR_NAV}").children().removeClass CSS_ACTIVE_SECTION section_name = $(this).data 'section' section = idash_content.find "##{section_name}" section.addClass CSS_ACTIVE_SECTION + $(this).addClass CSS_ACTIVE_SECTION location.hash = "#{HASH_LINK_PREFIX}#{section_name}" @@ -36,3 +45,16 @@ setup_instructor_dashboard = (idash_content) => link.click() else links.filter(".#{CSS_IDASH_DEFAULT_SECTION}").click() + + +# enable sections +setup_instructor_dashboard_sections = (idash_content) -> + window.x = idash_content + setup_section_data_download idash_content.find(".#{CSS_IDASH_SECTION}#data-download") + +setup_section_data_download = (section) -> + grade_config_btn = section.find("input[value='Grading Configuration']'") + grade_config_btn.click (e) -> + log "fetching grading config" + $.getJSON grade_config_btn.data('endpoint'), (data) -> + section.find('.dumped-data-display').html data['grading_config_summary'] diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index 5ebf4caec1b6..302bb5220d86 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -32,11 +32,11 @@

Instructor Dashboard

## links which are tied to idash-sections below. ## the links are acativated and handled in instructor_dashboard.coffee ## when the javascript loads, it clicks on idash-default-section -

[ - Course Info | - Enrollment | - Student Admin | - Data Download +

[ + Course Info | + Enrollment | + Student Admin | + Data Download ]

## each section corresponds to a section_data sub-dictionary provided by the view @@ -61,7 +61,7 @@

[ - +

From 68a260497c1a546fb534edc2167d85310b006c1b Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 15:27:35 -0400 Subject: [PATCH 13/92] tweak presentation --- lms/static/sass/course/instructor/_instructor_2.scss | 6 ++++++ .../courseware/instructor_dashboard_2/course_info.html | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index cd3b2af08b14..d21fcbe2f4bf 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -19,6 +19,12 @@ right: 50px; } + .instructor-nav { + .active-section { + color: #551A8B; + } + } + section.idash-section { // background-color: #0f0; display: none; diff --git a/lms/templates/courseware/instructor_dashboard_2/course_info.html b/lms/templates/courseware/instructor_dashboard_2/course_info.html index 111c96adf953..2b4fa581580c 100644 --- a/lms/templates/courseware/instructor_dashboard_2/course_info.html +++ b/lms/templates/courseware/instructor_dashboard_2/course_info.html @@ -9,13 +9,13 @@
- Started: - ${ section_data['course_info']['has_started'] } + Students Enrolled: + ${ section_data['course_info']['enrollment_count'] }
- Students Enrolled: - ${ section_data['course_info']['enrollment_count'] } + Started: + ${ section_data['course_info']['has_started'] }
From 8d29a1714dc5a1486250fe794a97f1f66aef944a Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Tue, 4 Jun 2013 16:54:04 -0400 Subject: [PATCH 14/92] add list profile summary to instructor dash --- lms/djangoapps/instructor/views/api.py | 40 ++++++++++++++++++- .../instructor/views/instructor_dashboard.py | 3 +- .../coffee/src/instructor_dashboard.coffee | 6 +++ .../instructor_dashboard_2.html | 2 +- lms/urls.py | 4 ++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 3198ca20fdb3..160389c6584a 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -28,6 +28,8 @@ from xmodule.modulestore.django import modulestore from student.models import CourseEnrollment import xmodule.graders as xmgraders +from django.contrib.auth.models import User, Group +from student.models import CourseEnrollment @ensure_csrf_cookie @@ -45,7 +47,7 @@ def grading_config(request, course_id): 'course_id': course_id, 'grading_config_summary': grading_config_summary, } - response = HttpResponse(json.dumps(response_payload)) + response = HttpResponse(json.dumps(response_payload), content_type="application/json") return response @@ -59,6 +61,42 @@ def enrolled_students_profiles(request, course_id): TODO accept requests for different attribute sets """ + enrollments = CourseEnrollment.objects.filter(course_id=course_id) + students = [enrollment.user for enrollment in enrollments] + + STUDENT_FEATURES = ['username', 'first_name', 'last_name', 'is_staff', 'email'] + PROFILE_FEATURES = ['year_of_birth', 'gender', 'level_of_education'] + + def extract_student(student): + student_dict = dict((feature, getattr(student, feature)) for feature in STUDENT_FEATURES) + profile = student.profile + profile_dict = dict((feature, getattr(profile, feature)) for feature in PROFILE_FEATURES) + student_dict.update(profile_dict) + return student_dict + + response_payload = { + 'course_id': course_id, + 'students': [extract_student(student) for student in students], + 'STUDENT_FEATURES': STUDENT_FEATURES, + 'PROFILE_FEATURES': PROFILE_FEATURES, + 'all_features': STUDENT_FEATURES + PROFILE_FEATURES, + } + 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 enrolled_students_choices_numbers(request, course_id): + """ + Respond with json of the distribution of students on fields select fields which have choices. + + TODO respond to csv requests as well + TODO accept requests for different attribute sets + """ + + EASY_CHOICE_FEATURES = ['year_of_birth', 'gender', 'level_of_education', 'language'] + OPEN_CHOICE_FEATURES = ['language', 'location/mailing_address'] raise NotImplementedError() diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 80e33c3cfa1d..b764d3be835d 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -95,6 +95,7 @@ def _section_student_admin(course_id): def _section_data_download(course_id): """ Provide data for the corresponding dashboard section """ section_data = { - 'grading_config_url': reverse('grading_config', kwargs={'course_id': course_id}), + 'grading_config_url': reverse('grading_config', kwargs={'course_id': course_id}), + 'enrolled_students_profiles_url': reverse('enrolled_students_profiles', 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 6eb432712484..37492a6fc84d 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -58,3 +58,9 @@ setup_section_data_download = (section) -> log "fetching grading config" $.getJSON grade_config_btn.data('endpoint'), (data) -> section.find('.dumped-data-display').html data['grading_config_summary'] + + list_studs_btn = section.find("input[value='List enrolled students with profile information']'") + list_studs_btn.click (e) -> + log "fetching student list" + $.getJSON list_studs_btn.data('endpoint'), (data) -> + section.find('.dumped-data-display').text JSON.stringify(data) diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index 302bb5220d86..5fd3c3865e57 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -58,7 +58,7 @@

[
- + diff --git a/lms/urls.py b/lms/urls.py index fc1c8bf2461e..ce6432842c7c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -269,8 +269,12 @@ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard$', 'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard_2"), + # api endpoints for instructor url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/grading_config$', 'instructor.views.api.grading_config', name="grading_config"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enrolled_students_profiles$', + 'instructor.views.api.enrolled_students_profiles', name="enrolled_students_profiles"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$', 'instructor.views.legacy.gradebook', name='gradebook'), From 9c4ba7f45f2181b945f7af2f2ea34b4c9ec997c1 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Wed, 5 Jun 2013 10:51:29 -0400 Subject: [PATCH 15/92] add profile_distribution endpoint, optimize queries --- lms/djangoapps/instructor/views/api.py | 48 ++++++++++++++++++++++---- lms/urls.py | 2 ++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 160389c6584a..f2428afe0ec5 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -29,7 +29,7 @@ from student.models import CourseEnrollment import xmodule.graders as xmgraders from django.contrib.auth.models import User, Group -from student.models import CourseEnrollment +from student.models import CourseEnrollment, UserProfile @ensure_csrf_cookie @@ -61,8 +61,9 @@ def enrolled_students_profiles(request, course_id): TODO accept requests for different attribute sets """ - enrollments = CourseEnrollment.objects.filter(course_id=course_id) - students = [enrollment.user for enrollment in enrollments] + # enrollments = CourseEnrollment.objects.filter(course_id=course_id) + # students = [enrollment.user for enrollment in enrollments] + students = User.objects.filter(courseenrollment__course_id=course_id) STUDENT_FEATURES = ['username', 'first_name', 'last_name', 'is_staff', 'email'] PROFILE_FEATURES = ['year_of_birth', 'gender', 'level_of_education'] @@ -76,7 +77,8 @@ def extract_student(student): response_payload = { 'course_id': course_id, - 'students': [extract_student(student) for student in students], + 'students': [extract_student(student) for student in students.all()], + 'students_count': students.count(), 'STUDENT_FEATURES': STUDENT_FEATURES, 'PROFILE_FEATURES': PROFILE_FEATURES, 'all_features': STUDENT_FEATURES + PROFILE_FEATURES, @@ -87,17 +89,49 @@ def extract_student(student): @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) -def enrolled_students_choices_numbers(request, course_id): +def profile_distribution(request, course_id): """ Respond with json of the distribution of students on fields select fields which have choices. + Features to query for are passed as a query parameter array ['gender', 'level_of_education'] + containing elements from feature lists below. + Right now the query list is of the format http://url?features=gender&features=level_of_education + TODO respond to csv requests as well - TODO accept requests for different attribute sets """ EASY_CHOICE_FEATURES = ['year_of_birth', 'gender', 'level_of_education', 'language'] OPEN_CHOICE_FEATURES = ['language', 'location/mailing_address'] - raise NotImplementedError() + + features = request.GET.getlist('features') + print "param: %s
class: %s
type: %s" %(str(features), str(features.__class__), str(type(features))) + + feature_results = {} + + def not_implemented_feature(feature): + feature_results[feature] = {'error': "do not know what to do for feature %s" % feature} + + for feature in features: + if feature in EASY_CHOICE_FEATURES: + # TODO generalize this switch + if feature == 'gender': + choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] + + feature_results[feature] = {} + for (short, full) in choices: + count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__gender=short).count() + feature_results[feature][full] = count + else: + not_implemented_feature(feature) + else: + not_implemented_feature(feature) + + response_payload = { + 'course_id': course_id, + 'feature_results': feature_results, + } + response = HttpResponse(json.dumps(response_payload), content_type="application/json") + return response def _dump_grading_context(course): diff --git a/lms/urls.py b/lms/urls.py index ce6432842c7c..5266c4b1f4fa 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -274,6 +274,8 @@ 'instructor.views.api.grading_config', name="grading_config"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enrolled_students_profiles$', '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[^/]+/[^/]+/[^/]+)/gradebook$', From 6e7187cd26791e56b2d47018293f4d60463b2a6e Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Wed, 5 Jun 2013 11:56:31 -0400 Subject: [PATCH 16/92] add more distributions to endpoint --- lms/djangoapps/instructor/views/api.py | 65 +++++++++++++++++--------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index f2428afe0ec5..f841b57892b9 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -18,6 +18,7 @@ from django.core.urlresolvers import reverse from django.utils.html import escape from django.http import HttpResponse, HttpResponseBadRequest +from django.db.models import Count from django.conf import settings from courseware.access import has_access, get_access_group_name, course_beta_test_group_name @@ -91,44 +92,66 @@ def extract_student(student): @cache_control(no_cache=True, no_store=True, must_revalidate=True) def profile_distribution(request, course_id): """ - Respond with json of the distribution of students on fields select fields which have choices. + Respond with json of the distribution of students over selected fields which have choices. - Features to query for are passed as a query parameter array ['gender', 'level_of_education'] - containing elements from feature lists below. - Right now the query list is of the format http://url?features=gender&features=level_of_education + Ask for features through the 'features' query parameter. + The features query parameter can be either a single feature name, or a json string of feature names. + e.g. + http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution?features=level_of_education + http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution?features=%5B%22year_of_birth%22%2C%22gender%22%5D + Example js query: + $.get("http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution", + {'features': JSON.stringify(['year_of_birth', 'gender'])}, + function(){console.log(arguments[0])}) + + TODO how should query parameter interpretation work? TODO respond to csv requests as well """ - EASY_CHOICE_FEATURES = ['year_of_birth', 'gender', 'level_of_education', 'language'] - OPEN_CHOICE_FEATURES = ['language', 'location/mailing_address'] + EASY_CHOICE_FEATURES = ['gender', 'level_of_education'] + OPEN_CHOICE_FEATURES = ['year_of_birth'] + # OPEN_CHOICE_FEATURES = ['language', 'location/mailing_address', 'language'] + + try: + features = json.loads(request.GET.get('features')) + except Exception: + features = [request.GET.get('features')] - features = request.GET.getlist('features') - print "param: %s
class: %s
type: %s" %(str(features), str(features.__class__), str(type(features))) + # print "param: %s
class: %s
type: %s" %(str(features), str(features.__class__), str(type(features))) feature_results = {} def not_implemented_feature(feature): - feature_results[feature] = {'error': "do not know what to do for feature %s" % feature} + feature_results[feature] = {'error': "can not find distribution for '%s'" % feature} for feature in features: - if feature in EASY_CHOICE_FEATURES: + # if feature in EASY_CHOICE_FEATURES: # TODO generalize this switch - if feature == 'gender': - choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] - - feature_results[feature] = {} - for (short, full) in choices: - count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__gender=short).count() - feature_results[feature][full] = count - else: - not_implemented_feature(feature) + if feature in EASY_CHOICE_FEATURES: + choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] + + feature_results[feature] = {} + for (short, full) in choices: + count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__gender=short).count() + feature_results[feature][full] = count + 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 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) + # distribution is of the form {'value1': 4, 'value2': 2, ...} + + feature_results[feature] = distribution else: not_implemented_feature(feature) response_payload = { - 'course_id': course_id, - 'feature_results': feature_results, + 'course_id': course_id, + 'queried_features': features, + 'available_features': ['gender', 'level_of_education', 'year_of_birth'], + 'feature_results': feature_results, } response = HttpResponse(json.dumps(response_payload), content_type="application/json") return response From bc6300e7cffd8bb3a785457080b17f59b15c1379 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Wed, 5 Jun 2013 12:50:26 -0400 Subject: [PATCH 17/92] add fronted for distributions, add comments --- lms/djangoapps/instructor/views/api.py | 36 +++++++++---- .../instructor/views/instructor_dashboard.py | 9 ++++ .../coffee/src/instructor_dashboard.coffee | 51 ++++++++++++++++++- .../sass/course/instructor/_instructor_2.scss | 6 +++ .../instructor_dashboard_2.html | 9 +++- 5 files changed, 99 insertions(+), 12 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index f841b57892b9..afecd208b0c2 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -126,15 +126,28 @@ def not_implemented_feature(feature): feature_results[feature] = {'error': "can not find distribution for '%s'" % feature} for feature in features: - # if feature in EASY_CHOICE_FEATURES: - # TODO generalize this switch - if feature in EASY_CHOICE_FEATURES: - choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] + feature_results[feature] = {} - feature_results[feature] = {} + if feature in EASY_CHOICE_FEATURES: + if feature == 'gender': + choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] + elif feature == 'level_of_education': + choices = [(short, full) for (short, full) in UserProfile.LEVEL_OF_EDUCATION_CHOICES] + [(None, 'No Data')] + else: + raise ValueError("feature request not implemented for feature %s" % feature) + + data = {} for (short, full) in choices: - count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__gender=short).count() - feature_results[feature][full] = count + if feature == 'gender': + count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__gender=short).count() + elif feature == 'level_of_education': + count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__level_of_education=short).count() + else: + raise ValueError("feature request not implemented for feature %s" % feature) + data[full] = count + + feature_results[feature]['data'] = data + feature_results[feature]['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() @@ -142,8 +155,8 @@ def not_implemented_feature(feature): distribution = dict((vald[feature], vald[feature + '__count']) for vald in query_distribution) # distribution is of the form {'value1': 4, 'value2': 2, ...} - - feature_results[feature] = distribution + feature_results[feature]['data'] = distribution + feature_results[feature]['type'] = 'OPEN_CHOICE' else: not_implemented_feature(feature) @@ -151,6 +164,11 @@ def not_implemented_feature(feature): 'course_id': course_id, 'queried_features': features, 'available_features': ['gender', 'level_of_education', 'year_of_birth'], + 'display_names': { + 'gender': 'Gender', + 'level_of_education': 'Level of Education', + 'year_of_birth': 'Year Of Birth', + }, 'feature_results': feature_results, } response = HttpResponse(json.dumps(response_payload), content_type="application/json") diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index b764d3be835d..97059670da85 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -40,6 +40,7 @@ def instructor_dashboard_2(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), } context = { @@ -99,3 +100,11 @@ def _section_data_download(course_id): 'enrolled_students_profiles_url': reverse('enrolled_students_profiles', kwargs={'course_id': course_id}), } return section_data + + +def _section_analytics(course_id): + """ Provide data for the corresponding dashboard section """ + section_data = { + '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 37492a6fc84d..0436d9de5fb5 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -23,20 +23,28 @@ $ => # 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 @@ -47,11 +55,14 @@ setup_instructor_dashboard = (idash_content) => links.filter(".#{CSS_IDASH_DEFAULT_SECTION}").click() -# enable sections +# call setup handlers for each section setup_instructor_dashboard_sections = (idash_content) -> - window.x = idash_content + log "setting up instructor dashboard sections" 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_data_download = (section) -> grade_config_btn = section.find("input[value='Grading Configuration']'") grade_config_btn.click (e) -> @@ -64,3 +75,39 @@ setup_section_data_download = (section) -> log "fetching student list" $.getJSON list_studs_btn.data('endpoint'), (data) -> section.find('.dumped-data-display').text JSON.stringify(data) + + +# setup the analytics section +setup_section_analytics = (section) -> + log "setting up instructor dashboard section - analytics" + + distribution_select = section.find('select#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 = $ '
+
+ +
+

From 914a7954bc9e2942d43dde83f32176ebfeaf8b30 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Fri, 21 Jun 2013 11:00:04 -0400 Subject: [PATCH 18/92] refactor from instructor views to analytics module --- lms/djangoapps/analytics/__init__.py | 0 lms/djangoapps/analytics/analytics.py | 55 ++++++++++ .../analytics/management/__init__.py | 0 .../analytics/management/commands/__init__.py | 0 .../analytics/profile_distribution.py | 63 +++++++++++ lms/djangoapps/analytics/tests/__init__.py | 0 lms/djangoapps/instructor/views/api.py | 100 ++---------------- .../coffee/src/instructor_dashboard.coffee | 3 +- 8 files changed, 128 insertions(+), 93 deletions(-) create mode 100644 lms/djangoapps/analytics/__init__.py create mode 100644 lms/djangoapps/analytics/analytics.py create mode 100644 lms/djangoapps/analytics/management/__init__.py create mode 100644 lms/djangoapps/analytics/management/commands/__init__.py create mode 100644 lms/djangoapps/analytics/profile_distribution.py create mode 100644 lms/djangoapps/analytics/tests/__init__.py diff --git a/lms/djangoapps/analytics/__init__.py b/lms/djangoapps/analytics/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/analytics/analytics.py b/lms/djangoapps/analytics/analytics.py new file mode 100644 index 000000000000..4453c05393d0 --- /dev/null +++ b/lms/djangoapps/analytics/analytics.py @@ -0,0 +1,55 @@ +""" +Student and course analytics. + +Serve miscellaneous course and student data +""" + +import xmodule.graders as xmgraders + +def _dump_grading_context(course): + """ + Dump information about course grading context (eg which problems are graded in what assignments) + Very useful for debugging grading_policy.json and policy.json + + Returns HTML string + """ + msg = "-----------------------------------------------------------------------------\n" + msg += "Course grader:\n" + + msg += '%s\n' % course.grader.__class__ + graders = {} + if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader): + msg += '\n' + msg += "Graded sections:\n" + for subgrader, category, weight in course.grader.sections: + msg += " subgrader=%s, type=%s, category=%s, weight=%s\n" % (subgrader.__class__, subgrader.type, category, weight) + subgrader.index = 1 + graders[subgrader.type] = subgrader + msg += "-----------------------------------------------------------------------------\n" + msg += "Listing grading context for course %s\n" % course.id + + gc = course.grading_context + msg += "graded sections:\n" + + msg += '%s\n' % gc['graded_sections'].keys() + for (gs, gsvals) in gc['graded_sections'].items(): + msg += "--> Section %s:\n" % (gs) + for sec in gsvals: + s = sec['section_descriptor'] + format = getattr(s.lms, 'format', None) + aname = '' + if format in graders: + g = graders[format] + aname = '%s %02d' % (g.short_label, g.index) + g.index += 1 + elif s.display_name in graders: + g = graders[s.display_name] + aname = '%s' % g.short_label + notes = '' + if getattr(s, 'score_by_attempt', False): + notes = ', score by attempt!' + msg += " %s (format=%s, Assignment=%s%s)\n" % (s.display_name, format, aname, notes) + msg += "all descriptors:\n" + msg += "length=%d\n" % len(gc['all_descriptors']) + msg = '
%s
' % msg.replace('<','<') + return msg diff --git a/lms/djangoapps/analytics/management/__init__.py b/lms/djangoapps/analytics/management/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/analytics/management/commands/__init__.py b/lms/djangoapps/analytics/management/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/analytics/profile_distribution.py b/lms/djangoapps/analytics/profile_distribution.py new file mode 100644 index 000000000000..abc97c141a61 --- /dev/null +++ b/lms/djangoapps/analytics/profile_distribution.py @@ -0,0 +1,63 @@ +""" +Profile Distributions +""" + +from django.db.models import Count +from django.contrib.auth.models import User, Group +from student.models import CourseEnrollment, UserProfile + +AVAILABLE_FEATURES = ['gender', 'level_of_education', 'year_of_birth'] + + +def get_profile_distribution(course_id, feature): + """ + Retrieve distribution of students over a given feature. + feature is one of AVAILABLE_FEATURES. + + Returna dictionary {'type': 'SOME_TYPE', 'data': {'key': 'val'}} + data types e.g. + EASY_CHOICE - choices with a restricted domain, e.g. level_of_education + OPEN_CHOICE - choices with a larger domain e.g. year_of_birth + """ + + EASY_CHOICE_FEATURES = ['gender', 'level_of_education'] + OPEN_CHOICE_FEATURES = ['year_of_birth'] + + feature_results = {} + + if not feature in AVAILABLE_FEATURES: + raise ValueError("unsupported feature requested for distribution '%s'" % feature) + + if feature in EASY_CHOICE_FEATURES: + if feature == 'gender': + choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] + elif feature == 'level_of_education': + choices = [(short, full) for (short, full) in UserProfile.LEVEL_OF_EDUCATION_CHOICES] + [(None, 'No Data')] + else: + raise ValueError("feature request not implemented for feature %s" % feature) + + data = {} + for (short, full) in choices: + if feature == 'gender': + count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__gender=short).count() + elif feature == 'level_of_education': + count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__level_of_education=short).count() + else: + raise ValueError("feature request not implemented for feature %s" % feature) + data[full] = count + + feature_results['data'] = data + 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 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) + # distribution is of the form {'value1': 4, 'value2': 2, ...} + feature_results['data'] = distribution + feature_results['type'] = 'OPEN_CHOICE' + else: + raise ValueError("feature requested for distribution has not been implemented but is advertised in AVAILABLE_FEATURES! '%s'" % feature) + + return feature_results diff --git a/lms/djangoapps/analytics/tests/__init__.py b/lms/djangoapps/analytics/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index afecd208b0c2..8568e7209e04 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -18,7 +18,6 @@ from django.core.urlresolvers import reverse from django.utils.html import escape from django.http import HttpResponse, HttpResponseBadRequest -from django.db.models import Count from django.conf import settings from courseware.access import has_access, get_access_group_name, course_beta_test_group_name @@ -28,9 +27,10 @@ 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 -import xmodule.graders as xmgraders from django.contrib.auth.models import User, Group -from student.models import CourseEnrollment, UserProfile + +from analytics.analytics import _dump_grading_context +from analytics.profile_distribution import get_profile_distribution @ensure_csrf_cookie @@ -109,56 +109,19 @@ def profile_distribution(request, course_id): TODO respond to csv requests as well """ - EASY_CHOICE_FEATURES = ['gender', 'level_of_education'] - OPEN_CHOICE_FEATURES = ['year_of_birth'] - # OPEN_CHOICE_FEATURES = ['language', 'location/mailing_address', 'language'] - try: features = json.loads(request.GET.get('features')) except Exception: features = [request.GET.get('features')] - # print "param: %s
class: %s
type: %s" %(str(features), str(features.__class__), str(type(features))) - feature_results = {} - def not_implemented_feature(feature): - feature_results[feature] = {'error': "can not find distribution for '%s'" % feature} - for feature in features: - feature_results[feature] = {} - - if feature in EASY_CHOICE_FEATURES: - if feature == 'gender': - choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] - elif feature == 'level_of_education': - choices = [(short, full) for (short, full) in UserProfile.LEVEL_OF_EDUCATION_CHOICES] + [(None, 'No Data')] - else: - raise ValueError("feature request not implemented for feature %s" % feature) - - data = {} - for (short, full) in choices: - if feature == 'gender': - count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__gender=short).count() - elif feature == 'level_of_education': - count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__level_of_education=short).count() - else: - raise ValueError("feature request not implemented for feature %s" % feature) - data[full] = count - - feature_results[feature]['data'] = data - feature_results[feature]['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 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) - # distribution is of the form {'value1': 4, 'value2': 2, ...} - feature_results[feature]['data'] = distribution - feature_results[feature]['type'] = 'OPEN_CHOICE' - else: - not_implemented_feature(feature) + try: + feature_results[feature] = get_profile_distribution(course_id, feature) + except: + feature_results[feature] = {'error': "can not find distribution for '%s'" % feature} + raise e response_payload = { 'course_id': course_id, @@ -173,50 +136,3 @@ def not_implemented_feature(feature): } response = HttpResponse(json.dumps(response_payload), content_type="application/json") return response - - -def _dump_grading_context(course): - """ - Dump information about course grading context (eg which problems are graded in what assignments) - Very useful for debugging grading_policy.json and policy.json - """ - msg = "-----------------------------------------------------------------------------\n" - msg += "Course grader:\n" - - msg += '%s\n' % course.grader.__class__ - graders = {} - if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader): - msg += '\n' - msg += "Graded sections:\n" - for subgrader, category, weight in course.grader.sections: - msg += " subgrader=%s, type=%s, category=%s, weight=%s\n" % (subgrader.__class__, subgrader.type, category, weight) - subgrader.index = 1 - graders[subgrader.type] = subgrader - msg += "-----------------------------------------------------------------------------\n" - msg += "Listing grading context for course %s\n" % course.id - - gc = course.grading_context - msg += "graded sections:\n" - - msg += '%s\n' % gc['graded_sections'].keys() - for (gs, gsvals) in gc['graded_sections'].items(): - msg += "--> Section %s:\n" % (gs) - for sec in gsvals: - s = sec['section_descriptor'] - format = getattr(s.lms, 'format', None) - aname = '' - if format in graders: - g = graders[format] - aname = '%s %02d' % (g.short_label, g.index) - g.index += 1 - elif s.display_name in graders: - g = graders[s.display_name] - aname = '%s' % g.short_label - notes = '' - if getattr(s, 'score_by_attempt', False): - notes = ', score by attempt!' - msg += " %s (format=%s, Assignment=%s%s)\n" % (s.display_name, format, aname, notes) - msg += "all descriptors:\n" - msg += "length=%d\n" % len(gc['all_descriptors']) - msg = '
%s
' % msg.replace('<','<') - return msg diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index 0436d9de5fb5..c0e3652ec8c3 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -82,7 +82,8 @@ setup_section_analytics = (section) -> log "setting up instructor dashboard section - analytics" distribution_select = section.find('select#distributions') - $.getJSON distribution_select.data('endpoint'), features: JSON.stringify(['']), (data) -> + # 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 From 3472ee17cb4ddec090f8ad98d8896fd91a119c8b Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 11:09:49 -0400 Subject: [PATCH 19/92] refactor from instructor views to analytics module (2) --- .../analytics/{analytics.py => basic.py} | 29 ++++++++++++++++++- ...ofile_distribution.py => distributions.py} | 10 +++---- lms/djangoapps/instructor/views/api.py | 26 +++++++++-------- 3 files changed, 47 insertions(+), 18 deletions(-) rename lms/djangoapps/analytics/{analytics.py => basic.py} (63%) rename lms/djangoapps/analytics/{profile_distribution.py => distributions.py} (89%) diff --git a/lms/djangoapps/analytics/analytics.py b/lms/djangoapps/analytics/basic.py similarity index 63% rename from lms/djangoapps/analytics/analytics.py rename to lms/djangoapps/analytics/basic.py index 4453c05393d0..2d271a7fa72d 100644 --- a/lms/djangoapps/analytics/analytics.py +++ b/lms/djangoapps/analytics/basic.py @@ -4,9 +4,36 @@ Serve miscellaneous course and student data """ +from django.contrib.auth.models import User import xmodule.graders as xmgraders -def _dump_grading_context(course): + +AVAILABLE_STUDENT_FEATURES = ['username', 'first_name', 'last_name', 'is_staff', 'email'] +AVAILABLE_PROFILE_FEATURES = ['year_of_birth', 'gender', 'level_of_education'] + + +def enrolled_students_profiles(course_id, features): + """ + Return array of student features e.g. [{?}, ...] + """ + # enrollments = CourseEnrollment.objects.filter(course_id=course_id) + # students = [enrollment.user for enrollment in enrollments] + students = User.objects.filter(courseenrollment__course_id=course_id) + + def extract_student(student): + student_features = [feature for feature in features if feature in AVAILABLE_STUDENT_FEATURES] + profile_features = [feature for feature in features if feature in AVAILABLE_PROFILE_FEATURES] + + student_dict = dict((feature, getattr(student, feature)) for feature in student_features) + profile = student.profile + profile_dict = dict((feature, getattr(profile, feature)) for feature in profile_features) + student_dict.update(profile_dict) + return student_dict + + return [extract_student(student) for student in students.all()], + + +def dump_grading_context(course): """ Dump information about course grading context (eg which problems are graded in what assignments) Very useful for debugging grading_policy.json and policy.json diff --git a/lms/djangoapps/analytics/profile_distribution.py b/lms/djangoapps/analytics/distributions.py similarity index 89% rename from lms/djangoapps/analytics/profile_distribution.py rename to lms/djangoapps/analytics/distributions.py index abc97c141a61..d6c015b8e328 100644 --- a/lms/djangoapps/analytics/profile_distribution.py +++ b/lms/djangoapps/analytics/distributions.py @@ -6,13 +6,13 @@ from django.contrib.auth.models import User, Group from student.models import CourseEnrollment, UserProfile -AVAILABLE_FEATURES = ['gender', 'level_of_education', 'year_of_birth'] +AVAILABLE_PROFILE_FEATURES = ['gender', 'level_of_education', 'year_of_birth'] -def get_profile_distribution(course_id, feature): +def profile_distribution(course_id, feature): """ Retrieve distribution of students over a given feature. - feature is one of AVAILABLE_FEATURES. + feature is one of AVAILABLE_PROFILE_FEATURES. Returna dictionary {'type': 'SOME_TYPE', 'data': {'key': 'val'}} data types e.g. @@ -25,7 +25,7 @@ def get_profile_distribution(course_id, feature): feature_results = {} - if not feature in AVAILABLE_FEATURES: + if not feature in AVAILABLE_PROFILE_FEATURES: raise ValueError("unsupported feature requested for distribution '%s'" % feature) if feature in EASY_CHOICE_FEATURES: @@ -58,6 +58,6 @@ def get_profile_distribution(course_id, feature): feature_results['data'] = distribution feature_results['type'] = 'OPEN_CHOICE' else: - raise ValueError("feature requested for distribution has not been implemented but is advertised in AVAILABLE_FEATURES! '%s'" % feature) + raise ValueError("feature requested for distribution has not been implemented but is advertised in AVAILABLE_PROFILE_FEATURES! '%s'" % feature) return feature_results diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 8568e7209e04..c4d35470d0f2 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -27,10 +27,10 @@ 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, Group +from django.contrib.auth.models import User -from analytics.analytics import _dump_grading_context -from analytics.profile_distribution import get_profile_distribution +import analytics.basic +import analytics.distributions @ensure_csrf_cookie @@ -42,7 +42,7 @@ def grading_config(request, course_id): TODO maybe this shouldn't be html already """ course = get_course_with_access(request.user, course_id, 'staff', depth=None) - grading_config_summary = _dump_grading_context(course) + grading_config_summary = analytics.basic.dump_grading_context(course) response_payload = { 'course_id': course_id, @@ -58,6 +58,8 @@ def enrolled_students_profiles(request, course_id): """ Respond with json which contains a summary of all enrolled students profile information. + Response {"students": [{-student-info-}, ...]} + TODO respond to csv requests as well TODO accept requests for different attribute sets """ @@ -76,13 +78,13 @@ def extract_student(student): student_dict.update(profile_dict) return student_dict + available_features = analytics.basic.AVAILABLE_STUDENT_FEATURES + analytics.basic.AVAILABLE_PROFILE_FEATURES + response_payload = { - 'course_id': course_id, - 'students': [extract_student(student) for student in students.all()], - 'students_count': students.count(), - 'STUDENT_FEATURES': STUDENT_FEATURES, - 'PROFILE_FEATURES': PROFILE_FEATURES, - 'all_features': STUDENT_FEATURES + PROFILE_FEATURES, + 'course_id': course_id, + 'students': analytics.basic.enrolled_students_profiles(course_id, available_features), + 'students_count': students.count(), + 'available_features': available_features } response = HttpResponse(json.dumps(response_payload), content_type="application/json") return response @@ -118,8 +120,8 @@ def profile_distribution(request, course_id): for feature in features: try: - feature_results[feature] = get_profile_distribution(course_id, feature) - except: + feature_results[feature] = analytics.distributions.profile_distribution(course_id, feature) + except Exception as e: feature_results[feature] = {'error': "can not find distribution for '%s'" % feature} raise e From b89f0992fb971690e1df5a7a4813cf1f4a5bb67d Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 12:33:35 -0400 Subject: [PATCH 20/92] add test endpoint --- lms/djangoapps/instructor/views/api.py | 11 ++++++++++- lms/urls.py | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index c4d35470d0f2..1c90883854fd 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -6,7 +6,6 @@ TODO add tracking """ -import csv import json import logging import os @@ -33,6 +32,16 @@ import analytics.distributions +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def test(request, course_id): + response_payload = { + 'testing': 'ok', + } + 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): diff --git a/lms/urls.py b/lms/urls.py index 5266c4b1f4fa..c70d30bb88ce 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -270,6 +270,8 @@ 'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard_2"), # api endpoints for instructor + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/test$', + 'instructor.views.api.test', name="instructor_dash_api_test"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/grading_config$', 'instructor.views.api.grading_config', name="grading_config"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enrolled_students_profiles$', From a159dc2f872aee54130adb2494e8ec4a8206f094 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 12:33:48 -0400 Subject: [PATCH 21/92] add create_csv_response to analytics.basic --- lms/djangoapps/analytics/basic.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/analytics/basic.py b/lms/djangoapps/analytics/basic.py index 2d271a7fa72d..cf4ec1f63cc1 100644 --- a/lms/djangoapps/analytics/basic.py +++ b/lms/djangoapps/analytics/basic.py @@ -4,6 +4,8 @@ Serve miscellaneous course and student data """ +import csv +from django.http import HttpResponse from django.contrib.auth.models import User import xmodule.graders as xmgraders @@ -33,10 +35,27 @@ def extract_student(student): return [extract_student(student) for student in students.all()], +def create_csv_response(filename, header, datarows): + """ + Create an HttpResponse with an attached .csv file + + header e.g. ['Name', 'Email'] + datarows e.g. [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ...] + """ + response = HttpResponse(mimetype='text/csv') + response['Content-Disposition'] = 'attachment; filename={0}'.format(filename) + writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) + writer.writerow(header) + for datarow in datarows: + encoded_row = [unicode(s).encode('utf-8') for s in datarow] + writer.writerow(encoded_row) + return response + + def dump_grading_context(course): """ Dump information about course grading context (eg which problems are graded in what assignments) - Very useful for debugging grading_policy.json and policy.json + Useful for debugging grading_policy.json and policy.json Returns HTML string """ From 68c44e03f67d4f9a997dc6f17e4533af0151f621 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 12:33:58 -0400 Subject: [PATCH 22/92] add useful names to html --- lms/static/coffee/src/instructor_dashboard.coffee | 13 +++++++------ .../instructor_dashboard_2.html | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index c0e3652ec8c3..e322d1458d79 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -64,17 +64,18 @@ setup_instructor_dashboard_sections = (idash_content) -> # setup the data download section setup_section_data_download = (section) -> - grade_config_btn = section.find("input[value='Grading Configuration']'") + list_studs_btn = section.find("input[name='list-profiles']'") + list_studs_btn.click (e) -> + log "fetching student list" + $.getJSON list_studs_btn.data('endpoint'), (data) -> + section.find('.dumped-data-display').text JSON.stringify(data) + + grade_config_btn = section.find("input[name='dump-gradeconf']'") grade_config_btn.click (e) -> log "fetching grading config" $.getJSON grade_config_btn.data('endpoint'), (data) -> section.find('.dumped-data-display').html data['grading_config_summary'] - list_studs_btn = section.find("input[value='List enrolled students with profile information']'") - list_studs_btn.click (e) -> - log "fetching student list" - $.getJSON list_studs_btn.data('endpoint'), (data) -> - section.find('.dumped-data-display').text JSON.stringify(data) # setup the analytics section diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index 358393e2cacd..c64ed5aa01cc 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -59,10 +59,10 @@

[
- - - - + + + +
From 1c64baaa5538bcf6087ba028c873203e98065e74 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 12:50:24 -0400 Subject: [PATCH 23/92] fix extra list level --- lms/djangoapps/analytics/basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/analytics/basic.py b/lms/djangoapps/analytics/basic.py index cf4ec1f63cc1..18becbb54ca5 100644 --- a/lms/djangoapps/analytics/basic.py +++ b/lms/djangoapps/analytics/basic.py @@ -32,7 +32,7 @@ def extract_student(student): student_dict.update(profile_dict) return student_dict - return [extract_student(student) for student in students.all()], + return [extract_student(student) for student in students.all()] def create_csv_response(filename, header, datarows): From 337fb47b6466844273a8ac2c67e9455b9325f7c0 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 12:51:37 -0400 Subject: [PATCH 24/92] add (noop) csv option, move data processing out of view --- lms/djangoapps/instructor/views/api.py | 23 +++++------------------ lms/urls.py | 2 +- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 1c90883854fd..e0b54d7fba8b 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -63,7 +63,7 @@ def grading_config(request, course_id): @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) -def enrolled_students_profiles(request, course_id): +def enrolled_students_profiles(request, course_id, csv=False): """ Respond with json which contains a summary of all enrolled students profile information. @@ -73,26 +73,13 @@ def enrolled_students_profiles(request, course_id): TODO accept requests for different attribute sets """ - # enrollments = CourseEnrollment.objects.filter(course_id=course_id) - # students = [enrollment.user for enrollment in enrollments] - students = User.objects.filter(courseenrollment__course_id=course_id) - - STUDENT_FEATURES = ['username', 'first_name', 'last_name', 'is_staff', 'email'] - PROFILE_FEATURES = ['year_of_birth', 'gender', 'level_of_education'] - - def extract_student(student): - student_dict = dict((feature, getattr(student, feature)) for feature in STUDENT_FEATURES) - profile = student.profile - profile_dict = dict((feature, getattr(profile, feature)) for feature in PROFILE_FEATURES) - student_dict.update(profile_dict) - return student_dict - available_features = analytics.basic.AVAILABLE_STUDENT_FEATURES + analytics.basic.AVAILABLE_PROFILE_FEATURES + data = analytics.basic.enrolled_students_profiles(course_id, available_features) response_payload = { 'course_id': course_id, - 'students': analytics.basic.enrolled_students_profiles(course_id, available_features), - 'students_count': students.count(), + 'students': data, + 'students_count': len(data), 'available_features': available_features } response = HttpResponse(json.dumps(response_payload), content_type="application/json") @@ -137,7 +124,7 @@ def profile_distribution(request, course_id): response_payload = { 'course_id': course_id, 'queried_features': features, - 'available_features': ['gender', 'level_of_education', 'year_of_birth'], + 'available_features': analytics.distributions.AVAILABLE_PROFILE_FEATURES, 'display_names': { 'gender': 'Gender', 'level_of_education': 'Level of Education', diff --git a/lms/urls.py b/lms/urls.py index c70d30bb88ce..8928dfd4f63c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -274,7 +274,7 @@ 'instructor.views.api.test', name="instructor_dash_api_test"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/grading_config$', 'instructor.views.api.grading_config', name="grading_config"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enrolled_students_profiles$', + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enrolled_students_profiles(?P/csv)?$', '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"), From 2b53627f393d763f2e61955c6d8ccd2c0d694c37 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 13:20:27 -0400 Subject: [PATCH 25/92] add csv option for student profile list --- lms/djangoapps/instructor/views/api.py | 33 ++++++++++++------- .../coffee/src/instructor_dashboard.coffee | 13 +++++--- .../instructor_dashboard_2.html | 1 + 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index e0b54d7fba8b..cb7c85a2e7c5 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -69,21 +69,32 @@ def enrolled_students_profiles(request, course_id, csv=False): Response {"students": [{-student-info-}, ...]} - TODO respond to csv requests as well TODO accept requests for different attribute sets """ available_features = analytics.basic.AVAILABLE_STUDENT_FEATURES + analytics.basic.AVAILABLE_PROFILE_FEATURES - - data = analytics.basic.enrolled_students_profiles(course_id, available_features) - response_payload = { - 'course_id': course_id, - 'students': data, - 'students_count': len(data), - 'available_features': available_features - } - response = HttpResponse(json.dumps(response_payload), content_type="application/json") - return response + queried_features = available_features + + student_data = analytics.basic.enrolled_students_profiles(course_id, queried_features) + + if not csv: + response_payload = { + 'course_id': course_id, + 'students': student_data, + 'students_count': len(student_data), + 'available_features': available_features + } + response = HttpResponse(json.dumps(response_payload), content_type="application/json") + return response + else: + header = queried_features + datarows = [] + for student in student_data: + ordered = sorted(student.items(), key=lambda (k, v): header.index(k)) + vals = map(lambda (k, v): v, ordered) + datarows.append(vals) + + return analytics.basic.create_csv_response("enrolled_profiles.csv", header, datarows) @ensure_csrf_cookie diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index e322d1458d79..78c731f38fda 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -67,17 +67,22 @@ setup_section_data_download = (section) -> list_studs_btn = section.find("input[name='list-profiles']'") list_studs_btn.click (e) -> log "fetching student list" - $.getJSON list_studs_btn.data('endpoint'), (data) -> - section.find('.dumped-data-display').text JSON.stringify(data) + url = $(this).data('endpoint') + if $(this).data 'csv' + url += '/csv' + location.href = url + else + $.getJSON url, (data) -> + section.find('.dumped-data-display').text JSON.stringify(data) grade_config_btn = section.find("input[name='dump-gradeconf']'") grade_config_btn.click (e) -> log "fetching grading config" - $.getJSON grade_config_btn.data('endpoint'), (data) -> + url = $(this).data('endpoint') + $.getJSON url, (data) -> section.find('.dumped-data-display').html data['grading_config_summary'] - # setup the analytics section setup_section_analytics = (section) -> log "setting up instructor dashboard section - analytics" diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index c64ed5aa01cc..89c060cfe1bc 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -60,6 +60,7 @@

[
+ From 44811f1233f6b7644b5e7fbb536401518b2ed9ec Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 13:20:34 -0400 Subject: [PATCH 26/92] remove #'s --- .../instructor_dashboard_2/instructor_dashboard_2.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index 89c060cfe1bc..c4e0e434c8bb 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -33,11 +33,11 @@

Instructor Dashboard

## the links are acativated and handled in instructor_dashboard.coffee ## when the javascript loads, it clicks on idash-default-section

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

## each section corresponds to a section_data sub-dictionary provided by the view From 1dc70723330768014113f56e1fac09178a481581 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 13:51:34 -0400 Subject: [PATCH 27/92] implement csv student list --- lms/djangoapps/analytics/basic.py | 19 -------- lms/djangoapps/analytics/csvs.py | 65 ++++++++++++++++++++++++++ lms/djangoapps/instructor/views/api.py | 11 ++--- 3 files changed, 68 insertions(+), 27 deletions(-) create mode 100644 lms/djangoapps/analytics/csvs.py diff --git a/lms/djangoapps/analytics/basic.py b/lms/djangoapps/analytics/basic.py index 18becbb54ca5..ca09122b2cde 100644 --- a/lms/djangoapps/analytics/basic.py +++ b/lms/djangoapps/analytics/basic.py @@ -4,8 +4,6 @@ Serve miscellaneous course and student data """ -import csv -from django.http import HttpResponse from django.contrib.auth.models import User import xmodule.graders as xmgraders @@ -35,23 +33,6 @@ def extract_student(student): return [extract_student(student) for student in students.all()] -def create_csv_response(filename, header, datarows): - """ - Create an HttpResponse with an attached .csv file - - header e.g. ['Name', 'Email'] - datarows e.g. [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ...] - """ - response = HttpResponse(mimetype='text/csv') - response['Content-Disposition'] = 'attachment; filename={0}'.format(filename) - writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) - writer.writerow(header) - for datarow in datarows: - encoded_row = [unicode(s).encode('utf-8') for s in datarow] - writer.writerow(encoded_row) - return response - - def dump_grading_context(course): """ Dump information about course grading context (eg which problems are graded in what assignments) diff --git a/lms/djangoapps/analytics/csvs.py b/lms/djangoapps/analytics/csvs.py new file mode 100644 index 000000000000..ece486644f3a --- /dev/null +++ b/lms/djangoapps/analytics/csvs.py @@ -0,0 +1,65 @@ +""" +Student and course analytics. + +Format and create csv responses +""" + +import csv +from django.http import HttpResponse + + +def create_csv_response(filename, header, datarows): + """ + Create an HttpResponse with an attached .csv file + + header e.g. ['Name', 'Email'] + datarows e.g. [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ...] + """ + response = HttpResponse(mimetype='text/csv') + response['Content-Disposition'] = 'attachment; filename={0}'.format(filename) + csvwriter = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) + csvwriter.writerow(header) + for datarow in datarows: + encoded_row = [unicode(s).encode('utf-8') for s in datarow] + csvwriter.writerow(encoded_row) + return response + + +def format_dictlist(dictlist): + """ + Convert from [ + { + 'label1': 'value1,1', + 'label2': 'value2,1', + 'label3': 'value3,1', + 'label4': 'value4,1', + }, + { + 'label1': 'value1,2', + 'label2': 'value2,2', + 'label3': 'value3,2', + 'label4': 'value4,2', + } + ] + + to { + 'header': ['label1', 'label2', 'label3', 'label4'], + 'datarows': ['value1,1', 'value2,1', 'value3,1', 'value4,1'], ['value1,2', 'value2,2', 'value3,2', 'value4,2'] + } + + Do not handle empty lists. + """ + + header = dictlist[0].keys() + + def dict_to_entry(d): + ordered = sorted(d.items(), key=lambda (k, v): header.index(k)) + vals = map(lambda (k, v): v, ordered) + return vals + + datarows = map(dict_to_entry, dictlist) + + return { + 'header': header, + 'datarows': datarows, + } diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index cb7c85a2e7c5..b07594bc1d26 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -30,6 +30,7 @@ import analytics.basic import analytics.distributions +import analytics.csvs @ensure_csrf_cookie @@ -87,14 +88,8 @@ def enrolled_students_profiles(request, course_id, csv=False): response = HttpResponse(json.dumps(response_payload), content_type="application/json") return response else: - header = queried_features - datarows = [] - for student in student_data: - ordered = sorted(student.items(), key=lambda (k, v): header.index(k)) - vals = map(lambda (k, v): v, ordered) - datarows.append(vals) - - return analytics.basic.create_csv_response("enrolled_profiles.csv", header, datarows) + formatted = analytics.csvs.format_dictlist(student_data) + return analytics.csvs.create_csv_response("enrolled_profiles.csv", formatted['header'], formatted['datarows']) @ensure_csrf_cookie From ea381f33644587915f43f196394c361374b547e1 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 14:03:30 -0400 Subject: [PATCH 28/92] change default query features --- lms/djangoapps/analytics/basic.py | 6 +++--- lms/djangoapps/instructor/views/api.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/analytics/basic.py b/lms/djangoapps/analytics/basic.py index ca09122b2cde..9acb505c4e79 100644 --- a/lms/djangoapps/analytics/basic.py +++ b/lms/djangoapps/analytics/basic.py @@ -9,7 +9,7 @@ AVAILABLE_STUDENT_FEATURES = ['username', 'first_name', 'last_name', 'is_staff', 'email'] -AVAILABLE_PROFILE_FEATURES = ['year_of_birth', 'gender', 'level_of_education'] +AVAILABLE_PROFILE_FEATURES = ['name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals'] def enrolled_students_profiles(course_id, features): @@ -18,7 +18,7 @@ def enrolled_students_profiles(course_id, features): """ # enrollments = CourseEnrollment.objects.filter(course_id=course_id) # students = [enrollment.user for enrollment in enrollments] - students = User.objects.filter(courseenrollment__course_id=course_id) + students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related('profile') def extract_student(student): student_features = [feature for feature in features if feature in AVAILABLE_STUDENT_FEATURES] @@ -30,7 +30,7 @@ def extract_student(student): student_dict.update(profile_dict) return student_dict - return [extract_student(student) for student in students.all()] + return [extract_student(student) for student in students] def dump_grading_context(course): diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index b07594bc1d26..8e11f53fcbb4 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -74,9 +74,10 @@ def enrolled_students_profiles(request, course_id, csv=False): """ available_features = analytics.basic.AVAILABLE_STUDENT_FEATURES + analytics.basic.AVAILABLE_PROFILE_FEATURES - queried_features = available_features + query_features = ['username', 'name', 'language', 'location', 'year_of_birth', 'gender', + 'level_of_education', 'mailing_address', 'goals'] - student_data = analytics.basic.enrolled_students_profiles(course_id, queried_features) + student_data = analytics.basic.enrolled_students_profiles(course_id, query_features) if not csv: response_payload = { From 7b130958adbc5f7c49b0aa6345937b4d2d280fd7 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 10 Jun 2013 15:33:00 -0400 Subject: [PATCH 29/92] add slickgrid assets --- .../images/ui-bg_flat_0_aaaaaa_40x100.png | Bin 0 -> 180 bytes .../images/ui-bg_flat_75_ffffff_40x100.png | Bin 0 -> 178 bytes .../images/ui-bg_glass_55_fbf9ee_1x400.png | Bin 0 -> 120 bytes .../images/ui-bg_glass_65_ffffff_1x400.png | Bin 0 -> 105 bytes .../images/ui-bg_glass_75_dadada_1x400.png | Bin 0 -> 111 bytes .../images/ui-bg_glass_75_e6e6e6_1x400.png | Bin 0 -> 110 bytes .../images/ui-bg_glass_95_fef1ec_1x400.png | Bin 0 -> 119 bytes .../ui-bg_highlight-soft_75_cccccc_1x100.png | Bin 0 -> 101 bytes .../images/ui-icons_222222_256x240.png | Bin 0 -> 4369 bytes .../images/ui-icons_2e83ff_256x240.png | Bin 0 -> 4369 bytes .../images/ui-icons_454545_256x240.png | Bin 0 -> 4369 bytes .../images/ui-icons_888888_256x240.png | Bin 0 -> 4369 bytes .../images/ui-icons_cd0a0a_256x240.png | Bin 0 -> 4369 bytes .../slickgrid/jquery-ui-1.8.16.custom.css | 409 ++ .../css/vendor/slickgrid/slick.grid.css | 157 + .../static/js/vendor/jquery.event.drag-2.2.js | 402 ++ .../static/js/vendor/jquery.event.drop-2.2.js | 302 ++ common/static/js/vendor/slick.core.js | 458 +++ common/static/js/vendor/slick.dataview.js | 1063 ++++++ common/static/js/vendor/slick.editors.js | 512 +++ common/static/js/vendor/slick.formatters.js | 59 + common/static/js/vendor/slick.grid.js | 3300 +++++++++++++++++ .../vendor/slick.groupitemmetadataprovider.js | 144 + common/static/js/vendor/slick.remotemodel.js | 164 + .../coffee/src/instructor_dashboard.coffee | 23 +- 25 files changed, 6992 insertions(+), 1 deletion(-) create mode 100644 common/static/css/vendor/slickgrid/images/ui-bg_flat_0_aaaaaa_40x100.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-bg_flat_75_ffffff_40x100.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-bg_glass_55_fbf9ee_1x400.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-bg_glass_65_ffffff_1x400.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-bg_glass_75_dadada_1x400.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-bg_glass_75_e6e6e6_1x400.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-bg_glass_95_fef1ec_1x400.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-bg_highlight-soft_75_cccccc_1x100.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-icons_222222_256x240.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-icons_2e83ff_256x240.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-icons_454545_256x240.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-icons_888888_256x240.png create mode 100644 common/static/css/vendor/slickgrid/images/ui-icons_cd0a0a_256x240.png create mode 100644 common/static/css/vendor/slickgrid/jquery-ui-1.8.16.custom.css create mode 100644 common/static/css/vendor/slickgrid/slick.grid.css create mode 100644 common/static/js/vendor/jquery.event.drag-2.2.js create mode 100644 common/static/js/vendor/jquery.event.drop-2.2.js create mode 100644 common/static/js/vendor/slick.core.js create mode 100644 common/static/js/vendor/slick.dataview.js create mode 100644 common/static/js/vendor/slick.editors.js create mode 100644 common/static/js/vendor/slick.formatters.js create mode 100644 common/static/js/vendor/slick.grid.js create mode 100644 common/static/js/vendor/slick.groupitemmetadataprovider.js create mode 100644 common/static/js/vendor/slick.remotemodel.js diff --git a/common/static/css/vendor/slickgrid/images/ui-bg_flat_0_aaaaaa_40x100.png b/common/static/css/vendor/slickgrid/images/ui-bg_flat_0_aaaaaa_40x100.png new file mode 100644 index 0000000000000000000000000000000000000000..5b5dab2ab7b1c50dea9cfe73dc5a269a92d2d4b4 GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F!3HG1q!d*FscKIb$B>N1x91EQ4=4yQ7#`R^ z$vje}bP0l+XkK DSH>_4 literal 0 HcmV?d00001 diff --git a/common/static/css/vendor/slickgrid/images/ui-bg_flat_75_ffffff_40x100.png b/common/static/css/vendor/slickgrid/images/ui-bg_flat_75_ffffff_40x100.png new file mode 100644 index 0000000000000000000000000000000000000000..ac8b229af950c29356abf64a6c4aa894575445f0 GIT binary patch literal 178 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F!3HG1q!d*FsY*{5$B>N1x91EQ4=4yQYz+E8 zPo9&<{J;c_6SHRil>2s{Zw^OT)6@jj2u|u!(plXsM>LJD`vD!n;OXk;vd$@?2>^GI BH@yG= literal 0 HcmV?d00001 diff --git a/common/static/css/vendor/slickgrid/images/ui-bg_glass_55_fbf9ee_1x400.png b/common/static/css/vendor/slickgrid/images/ui-bg_glass_55_fbf9ee_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..ad3d6346e00f246102f72f2e026ed0491988b394 GIT binary patch literal 120 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnour0hLi978O6-<~(*I$*%ybaDOn z{W;e!B}_MSUQoPXhYd^Y6RUoS1yepnPx`2Kz)7OXQG!!=-jY=F+d2OOy?#DnJ32>z UEim$g7SJdLPgg&ebxsLQ09~*s;{X5v literal 0 HcmV?d00001 diff --git a/common/static/css/vendor/slickgrid/images/ui-bg_glass_65_ffffff_1x400.png b/common/static/css/vendor/slickgrid/images/ui-bg_glass_65_ffffff_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..42ccba269b6e91bef12ad0fa18be651b5ef0ee68 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnouqzpV=978O6-=0?FV^9z|eBtf= z|7WztIJ;WT>{+tN>ySr~=F{k$>;_x^_y?afmf9pRKH0)6?eSP?3s5hEr>mdKI;Vst E0O;M1& literal 0 HcmV?d00001 diff --git a/common/static/css/vendor/slickgrid/images/ui-bg_glass_75_dadada_1x400.png b/common/static/css/vendor/slickgrid/images/ui-bg_glass_75_dadada_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..5a46b47cb16631068aee9e0bd61269fc4e95e5cd GIT binary patch literal 111 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnouq|7{B978O6lPf+wIa#m9#>Unb zm^4K~wN3Zq+uP{vDV26o)#~38k_!`W=^oo1w6ixmPC4R1b Tyd6G3lNdZ*{an^LB{Ts5`idse literal 0 HcmV?d00001 diff --git a/common/static/css/vendor/slickgrid/images/ui-bg_highlight-soft_75_cccccc_1x100.png b/common/static/css/vendor/slickgrid/images/ui-bg_highlight-soft_75_cccccc_1x100.png new file mode 100644 index 0000000000000000000000000000000000000000..7c9fa6c6edcfcdd3e5b77e6f547b719e6fc66e30 GIT binary patch literal 101 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 literal 0 HcmV?d00001 diff --git a/common/static/css/vendor/slickgrid/images/ui-icons_2e83ff_256x240.png b/common/static/css/vendor/slickgrid/images/ui-icons_2e83ff_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..09d1cdc856c292c4ab6dd818c7543ac0828bd616 GIT binary patch literal 4369 zcmd^?`8O2)_s3@pGmLE*`#M>&Z`mr_kcu#tBo!IbqU=l7VaSrbQrTh%5m}S08Obh0 zGL{*mi8RK}U~J#s@6Y%1S9~7lb?$xLU+y{go_o*h`AW1wUF3v{Kmh;%r@5J_9RL9Q zdj+hqg8o{9`K7(TZrR4t{=9O`!T-(~c=yEWZ{eswJJe->5bP8)t4;f(Y*i_HU*sLM z2=7-8guZ}@*(HhVC)Mqgr$3T8?#a(hu& z?Kzuw!O%PM>AicSW`_U(cbvJYv3{HfpIP~Q>@$^c588E$vv)V2c|Mr% zuFO$+I~Hg@u}wPm17n%}j1Y+Pbu!bt?iPkjGAo7>9eRN0FZz3X2_QZj+V!}+*8oBQ z_=iI^_TCA;Ea2tPmRNOeX3+VM>KL;o1(h`c@`6Ah`vdH<&+$yTg)jGWW72T}6J`kUAv?2CgyV zrs0y@Fpvpj@kWVE0TzL@Cy#qHn~kgensb{hIm6J&I8hkoNHOz6o1QQ3QM4NZyu?;= zLd>`wPT*uGr+6vAxYv3k8{gMDR>tO}UavDKzzyi6hvbuP=XQ4Y|A)r4#B$U(q7{1Z z0iLeSjo3;T*diS*me%4|!s23l@>R}rn@#Zc{<%CFt;?gd5S<)b=8Yz32U zBBLprntW3RE3f|uNX5Aw|I(IlJjW-Byd?QFFRk%hLU}O*YyYQel}WcXilLMJp9cB4 z)E?D+*Y4zai&XY!>niMfTW-2pp-^KFT93%Leig@uoQGPYRCva-`w#orm`is`p8b4s zxD462;f*^XO$=3by=VzN9i@xxr<1w=pcxl!$!fjWt|fYmq1@@badT?v`d zIi$|e$Ji}FXsiVYf)?pN1R0LBw;+)B5aUJj2fP+=m;=_Eho84g%Jq#@MLPSQEX*@T z6sZb)m?)zby>{j1)(;rRML|gKSs+9jorf-XhQJ2Jyt5Cqc*`S3iX@A5C3jvgAns|4 z*|)YQ%Kmsj+YZ53;nMqh|AFvehUV-9R;1ZZ;w5r9l}8hjSw@#k;>)$P*r%)=Extyu zB!$Kd-F?*50aJ2;TNTR-fc8B{KAq3!vW{g$LlGPfGW+%#CXU zJDcMsvyT2`x~v>>w8@yssoA`KuIZ98CLU{Ia%*nW3G4t}@ApsbC@o^WCqL>OXx>Y^ zSuVWEQ;3=A=@RxCnt0>G@#(VWBQ`0$qTwA#e>SX{_N~JWGsBxFHCw|5|?CzDi>92F-^=b*8sMXnhUJdb!>yGD2nhN@{582 zRPcxuDzs&;8De)>_J19z{0xppXQop#T_5ejGCKv@l>$O#DA-@X{y_1B-AsiU)H}DR z3xDZ8G`amV_WmA&8!W=@jgm|%bnwH%qkg(@J$hLaSV zC-rXIFMM%y<|Gb)o?j zpe-`dJ*N5tC-iH)d0CgLdBsw*C!ST9hY1EkI|Y(&=p&dH&q;a&7HXa5#_wtMsenQL zcpyhwx)Ppw@XmVz?P)DI#^ee1oC!i`>>Jq1ESk-OuQ(Pbv=s{A0AjM@rw#FaU;RUh z*At0{U*NtGVY_-JcuG$?zuuf%ZBTWxKU2yf?iN#-MRWs>A*2;p0G1Tp3d29u5RbnY zDOON-G|PidOOGeybnbzu7UVv71l!b=w7eU5l*{EdKuoKu`#LZ}|fnUr-+lSST9(MTT`0tqOG z#+Q_=lXe-=;rE4u8s~;%i~~ z8v&&+VPeXG=2zw9B5sR$e?R(n%nf?p-(BCZ8}x!_-9T+LT;2=Zu?Wv)j3#>35$6dR z4*7xmI)#06qjh#sXvX(%`#D1mD8fn1G~I;l%Dk{pw)}>_{+3^Fv_q)>2#de5qGCId zPz?ix-3954nM&u@vaw{o%-#HU%_bLJMO#@enR^&B{3ihWdoU6%pBJ`o>im+b-c6r-;c{vd0Z_)`75$jApy2?!9G4_FGa)iZ~9`6VELiYM+n!-mUfvfm{jt zC?!1=%pxJhF>vyQ47Q}R;O48pxgMs)rz$SbM&jkp<6X$r4DHWg>ZnGB-$r2o1*nL# zW0^*itcRY_^Uv^XgQP>W#>KQgM~l{;S(GkVW@&vld^AhWzG^m|9#0#USbM>^en{k2 za8~DTL`(Q~=ofsL&Fc`!L6r~qTnnGo8r98<(aG*<0%aNEr!!BIyY>VV82kxhR%d>V(lN&#BId#urK_i~Pe6?>C~J!pU_lRon#&S_cXoQv;poG8FK4atc

N)npz1~X%p6x{M(Gw!!H=!}lmO0Xr*8ewyH(Q+>oy`fxQkxJ zzzB$)%*xM4s_2(O>)T-QXhwP|&DZam#{O+47q|WKfz_ZL-MypRN~o{fE*I#6@eM?I zs%f-6{Lz6j7rB#U$%O$~TIT!j?|Ip1CpSmb=JA9qCY3-mQf|fVCxswPjok|VofUEP zW5^pTd5B;wRkyW%1a;nYHB$ef6Pv8^);`m0jv6p72iNJl+sVBqZugsq6cq_pyNREi z>GN!h6ZQ6`aOMr_2KI@j=XR@$aJj(2jcpY?>f=2kMV@di5W7Swj?ug10zRe}F1nR* ztMm6+T^)LJe^SzGgSxahQajq0h7#|8oMV0>D~*N}jl?9_X`ka42R4@rryDc3o(c$R?1*!1O9zleSOczw zYPS3~xbJ$~C(3+D7Zkrfjs_lneY^zv^kHmxt)aqZ!aeGABHZ`gvA&K`72z}ihI$Ht z9V&)wQy0g@R9irwbf!{uE&_J2l9jXz^Vj#=qA77*3Pd9OjrE_tKDHADd!AjFQv(ji zct-BMUt9()1Ox!dsI_h1(^F_U)_QJrx|%+y`zWWlD4=Nd?JQ=URh0*{fb1!o4tS(H z^r_T(8t1SAHf1oduG+X^*EC_kL(!QnXL6Hp);449yO&1xE>MXGqT)t10lzvALllX;;Q)RiJX$dm zlR8ep5-GdHmRm9?N#QCjNUA);vC03Gw6yds6^?c4;(MH>;O5xmQ2nGK3Dmk8i*v5t z-{jJsQq30%z}0`g7SN-yN`l-`@6rkJ|V|>18`MV zwUeH}DxWw&h+A+Dn|4|YNr&EfKS`Hz_NkeW3*sI5Rq-J&FzG=!{-K`n65#7O%^&f> z`PkqxyC_K)>781~7H${^Nj{`>XEa&OPqqQhySR5%w2{5+sEakXXHazJp6~LP2QKDx zpkvZrkDOa+A4BbqqX6ls&O)5-Q7`qkZ_?6~c-wQ9tseNtET;nhEOL^`*naKwcMX;R zbto&a;oTR0s;vjfj3wigUg)Sj)!OHQfZoJwAsWYI1A4ntz>X=W4s|y?tUk1r=>#Ct zf+?hq^>rQ3$KNboG$UhCdEmp{qAR13DK$f0ES7kAG~7q+g!jfVq`1b5+c62N^0%~o zKw91o@Wv;0EW*7fINAX3O~L-V{`;xB0q()#^HKZOlLrXVL*Dtw-$SUp8*_J{r( zW`6r`cz0yZQ#f0#*y+m64{bs7GP|2V$phf42rswJB?s@9qf;Bfc^pm-ZS#^5dkG{u zzv;l&B$NYcegSqAnjnPN1?17VUQbPummcWry((85IFB(pFQNGN{hhN$Fv?~l_fr?| z9=%dK(+;kZ(8=mwptjwC-ikBD$Z{l2++~*8wq5ynF<+PNlZI7ba5V#fg~L}kE;UH5 zJ;{P(`G{tNl&z5rUiH~e{I>GT8~9&*(J;Myx9z5P!db!F8RTII^I7c)HU=ss*bYB` zgwiIMZ_q>KEC$4lFm+Afvu6^$X1jm1rB*4H)-EIO5Rvz_p24?OkJ zovD4{-1KA6*oL?a;3qR7GZRB!cE5oAdA#M@{w+fGgsJ-lSmQ^-?8E&Q%tbmjd=@gZ z(}Mg*jsDf6Z)|7s%@9pc-tuw5W&zqUXjv2bVkC%-X?O3F72W4EsIl#1e>Mdz=X4k*_>VxCu_2?jjg16N*5fwC-36OW&;Sz}@jMn}hgJdEd pO;bST+>R{W-aENZYk%(=^(_R5N$LmL{Qc?!%+I4tt4z=_{|902Wu5>4 literal 0 HcmV?d00001 diff --git a/common/static/css/vendor/slickgrid/images/ui-icons_454545_256x240.png b/common/static/css/vendor/slickgrid/images/ui-icons_454545_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..59bd45b907c4fd965697774ce8c5fc6b2fd9c105 GIT binary patch literal 4369 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 oBo%DI*Kv;w;*%(i9W@f3_WCF#rGn literal 0 HcmV?d00001 diff --git a/common/static/css/vendor/slickgrid/images/ui-icons_cd0a0a_256x240.png b/common/static/css/vendor/slickgrid/images/ui-icons_cd0a0a_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..2ab019b73ec11a485fa09378f3a0e155194f6a5d GIT binary patch literal 4369 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 0 && event.which != dd.which ) + return; + // check for suppressed selector + if ( $( event.target ).is( dd.not ) ) + return; + // check for handle selector + if ( dd.handle && !$( event.target ).closest( dd.handle, event.currentTarget ).length ) + return; + + drag.touched = event.type == 'touchstart' ? this : null; + dd.propagates = 1; + dd.mousedown = this; + dd.interactions = [ drag.interaction( this, dd ) ]; + dd.target = event.target; + dd.pageX = event.pageX; + dd.pageY = event.pageY; + dd.dragging = null; + // handle draginit event... + results = drag.hijack( event, "draginit", dd ); + // early cancel + if ( !dd.propagates ) + return; + // flatten the result set + results = drag.flatten( results ); + // insert new interaction elements + if ( results && results.length ){ + dd.interactions = []; + $.each( results, function(){ + dd.interactions.push( drag.interaction( this, dd ) ); + }); + } + // remember how many interactions are propagating + dd.propagates = dd.interactions.length; + // locate and init the drop targets + if ( dd.drop !== false && $special.drop ) + $special.drop.handler( event, dd ); + // disable text selection + drag.textselect( false ); + // bind additional events... + if ( drag.touched ) + $event.add( drag.touched, "touchmove touchend", drag.handler, dd ); + else + $event.add( document, "mousemove mouseup", drag.handler, dd ); + // helps prevent text selection or scrolling + if ( !drag.touched || dd.live ) + return false; + }, + + // returns an interaction object + interaction: function( elem, dd ){ + var offset = $( elem )[ dd.relative ? "position" : "offset" ]() || { top:0, left:0 }; + return { + drag: elem, + callback: new drag.callback(), + droppable: [], + offset: offset + }; + }, + + // handle drag-releatd DOM events + handler: function( event ){ + // read the data before hijacking anything + var dd = event.data; + // handle various events + switch ( event.type ){ + // mousemove, check distance, start dragging + case !dd.dragging && 'touchmove': + event.preventDefault(); + case !dd.dragging && 'mousemove': + // drag tolerance, x² + y² = distance² + if ( Math.pow( event.pageX-dd.pageX, 2 ) + Math.pow( event.pageY-dd.pageY, 2 ) < Math.pow( dd.distance, 2 ) ) + break; // distance tolerance not reached + event.target = dd.target; // force target from "mousedown" event (fix distance issue) + drag.hijack( event, "dragstart", dd ); // trigger "dragstart" + if ( dd.propagates ) // "dragstart" not rejected + dd.dragging = true; // activate interaction + // mousemove, dragging + case 'touchmove': + event.preventDefault(); + case 'mousemove': + if ( dd.dragging ){ + // trigger "drag" + drag.hijack( event, "drag", dd ); + if ( dd.propagates ){ + // manage drop events + if ( dd.drop !== false && $special.drop ) + $special.drop.handler( event, dd ); // "dropstart", "dropend" + break; // "drag" not rejected, stop + } + event.type = "mouseup"; // helps "drop" handler behave + } + // mouseup, stop dragging + case 'touchend': + case 'mouseup': + default: + if ( drag.touched ) + $event.remove( drag.touched, "touchmove touchend", drag.handler ); // remove touch events + else + $event.remove( document, "mousemove mouseup", drag.handler ); // remove page events + if ( dd.dragging ){ + if ( dd.drop !== false && $special.drop ) + $special.drop.handler( event, dd ); // "drop" + drag.hijack( event, "dragend", dd ); // trigger "dragend" + } + drag.textselect( true ); // enable text selection + // if suppressing click events... + if ( dd.click === false && dd.dragging ) + $.data( dd.mousedown, "suppress.click", new Date().getTime() + 5 ); + dd.dragging = drag.touched = false; // deactivate element + break; + } + }, + + // re-use event object for custom events + hijack: function( event, type, dd, x, elem ){ + // not configured + if ( !dd ) + return; + // remember the original event and type + var orig = { event:event.originalEvent, type:event.type }, + // is the event drag related or drog related? + mode = type.indexOf("drop") ? "drag" : "drop", + // iteration vars + result, i = x || 0, ia, $elems, callback, + len = !isNaN( x ) ? x : dd.interactions.length; + // modify the event type + event.type = type; + // remove the original event + event.originalEvent = null; + // initialize the results + dd.results = []; + // handle each interacted element + do if ( ia = dd.interactions[ i ] ){ + // validate the interaction + if ( type !== "dragend" && ia.cancelled ) + continue; + // set the dragdrop properties on the event object + callback = drag.properties( event, dd, ia ); + // prepare for more results + ia.results = []; + // handle each element + $( elem || ia[ mode ] || dd.droppable ).each(function( p, subject ){ + // identify drag or drop targets individually + callback.target = subject; + // force propagtion of the custom event + event.isPropagationStopped = function(){ return false; }; + // handle the event + result = subject ? $event.dispatch.call( subject, event, callback ) : null; + // stop the drag interaction for this element + if ( result === false ){ + if ( mode == "drag" ){ + ia.cancelled = true; + dd.propagates -= 1; + } + if ( type == "drop" ){ + ia[ mode ][p] = null; + } + } + // assign any dropinit elements + else if ( type == "dropinit" ) + ia.droppable.push( drag.element( result ) || subject ); + // accept a returned proxy element + if ( type == "dragstart" ) + ia.proxy = $( drag.element( result ) || ia.drag )[0]; + // remember this result + ia.results.push( result ); + // forget the event result, for recycling + delete event.result; + // break on cancelled handler + if ( type !== "dropinit" ) + return result; + }); + // flatten the results + dd.results[ i ] = drag.flatten( ia.results ); + // accept a set of valid drop targets + if ( type == "dropinit" ) + ia.droppable = drag.flatten( ia.droppable ); + // locate drop targets + if ( type == "dragstart" && !ia.cancelled ) + callback.update(); + } + while ( ++i < len ) + // restore the original event & type + event.type = orig.type; + event.originalEvent = orig.event; + // return all handler results + return drag.flatten( dd.results ); + }, + + // extend the callback object with drag/drop properties... + properties: function( event, dd, ia ){ + var obj = ia.callback; + // elements + obj.drag = ia.drag; + obj.proxy = ia.proxy || ia.drag; + // starting mouse position + obj.startX = dd.pageX; + obj.startY = dd.pageY; + // current distance dragged + obj.deltaX = event.pageX - dd.pageX; + obj.deltaY = event.pageY - dd.pageY; + // original element position + obj.originalX = ia.offset.left; + obj.originalY = ia.offset.top; + // adjusted element position + obj.offsetX = obj.originalX + obj.deltaX; + obj.offsetY = obj.originalY + obj.deltaY; + // assign the drop targets information + obj.drop = drag.flatten( ( ia.drop || [] ).slice() ); + obj.available = drag.flatten( ( ia.droppable || [] ).slice() ); + return obj; + }, + + // determine is the argument is an element or jquery instance + element: function( arg ){ + if ( arg && ( arg.jquery || arg.nodeType == 1 ) ) + return arg; + }, + + // flatten nested jquery objects and arrays into a single dimension array + flatten: function( arr ){ + return $.map( arr, function( member ){ + return member && member.jquery ? $.makeArray( member ) : + member && member.length ? drag.flatten( member ) : member; + }); + }, + + // toggles text selection attributes ON (true) or OFF (false) + textselect: function( bool ){ + $( document )[ bool ? "unbind" : "bind" ]("selectstart", drag.dontstart ) + .css("MozUserSelect", bool ? "" : "none" ); + // .attr("unselectable", bool ? "off" : "on" ) + document.unselectable = bool ? "off" : "on"; + }, + + // suppress "selectstart" and "ondragstart" events + dontstart: function(){ + return false; + }, + + // a callback instance contructor + callback: function(){} + +}; + +// callback methods +drag.callback.prototype = { + update: function(){ + if ( $special.drop && this.available.length ) + $.each( this.available, function( i ){ + $special.drop.locate( this, i ); + }); + } +}; + +// patch $.event.$dispatch to allow suppressing clicks +var $dispatch = $event.dispatch; +$event.dispatch = function( event ){ + if ( $.data( this, "suppress."+ event.type ) - new Date().getTime() > 0 ){ + $.removeData( this, "suppress."+ event.type ); + return; + } + return $dispatch.apply( this, arguments ); +}; + +// event fix hooks for touch events... +var touchHooks = +$event.fixHooks.touchstart = +$event.fixHooks.touchmove = +$event.fixHooks.touchend = +$event.fixHooks.touchcancel = { + props: "clientX clientY pageX pageY screenX screenY".split( " " ), + filter: function( event, orig ) { + if ( orig ){ + var touched = ( orig.touches && orig.touches[0] ) + || ( orig.changedTouches && orig.changedTouches[0] ) + || null; + // iOS webkit: touchstart, touchmove, touchend + if ( touched ) + $.each( touchHooks.props, function( i, prop ){ + event[ prop ] = touched[ prop ]; + }); + } + return event; + } +}; + +// share the same special event configuration with related events... +$special.draginit = $special.dragstart = $special.dragend = drag; + +})( jQuery ); \ No newline at end of file diff --git a/common/static/js/vendor/jquery.event.drop-2.2.js b/common/static/js/vendor/jquery.event.drop-2.2.js new file mode 100644 index 000000000000..7599ef91e7fe --- /dev/null +++ b/common/static/js/vendor/jquery.event.drop-2.2.js @@ -0,0 +1,302 @@ +/*! + * jquery.event.drop - v 2.2 + * Copyright (c) 2010 Three Dub Media - http://threedubmedia.com + * Open Source MIT License - http://threedubmedia.com/code/license + */ +// Created: 2008-06-04 +// Updated: 2012-05-21 +// REQUIRES: jquery 1.7.x, event.drag 2.2 + +;(function($){ // secure $ jQuery alias + +// Events: drop, dropstart, dropend + +// add the jquery instance method +$.fn.drop = function( str, arg, opts ){ + // figure out the event type + var type = typeof str == "string" ? str : "", + // figure out the event handler... + fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null; + // fix the event type + if ( type.indexOf("drop") !== 0 ) + type = "drop"+ type; + // were options passed + opts = ( str == fn ? arg : opts ) || {}; + // trigger or bind event handler + return fn ? this.bind( type, opts, fn ) : this.trigger( type ); +}; + +// DROP MANAGEMENT UTILITY +// returns filtered drop target elements, caches their positions +$.drop = function( opts ){ + opts = opts || {}; + // safely set new options... + drop.multi = opts.multi === true ? Infinity : + opts.multi === false ? 1 : !isNaN( opts.multi ) ? opts.multi : drop.multi; + drop.delay = opts.delay || drop.delay; + drop.tolerance = $.isFunction( opts.tolerance ) ? opts.tolerance : + opts.tolerance === null ? null : drop.tolerance; + drop.mode = opts.mode || drop.mode || 'intersect'; +}; + +// local refs (increase compression) +var $event = $.event, +$special = $event.special, +// configure the drop special event +drop = $.event.special.drop = { + + // these are the default settings + multi: 1, // allow multiple drop winners per dragged element + delay: 20, // async timeout delay + mode: 'overlap', // drop tolerance mode + + // internal cache + targets: [], + + // the key name for stored drop data + datakey: "dropdata", + + // prevent bubbling for better performance + noBubble: true, + + // count bound related events + add: function( obj ){ + // read the interaction data + var data = $.data( this, drop.datakey ); + // count another realted event + data.related += 1; + }, + + // forget unbound related events + remove: function(){ + $.data( this, drop.datakey ).related -= 1; + }, + + // configure the interactions + setup: function(){ + // check for related events + if ( $.data( this, drop.datakey ) ) + return; + // initialize the drop element data + var data = { + related: 0, + active: [], + anyactive: 0, + winner: 0, + location: {} + }; + // store the drop data on the element + $.data( this, drop.datakey, data ); + // store the drop target in internal cache + drop.targets.push( this ); + }, + + // destroy the configure interaction + teardown: function(){ + var data = $.data( this, drop.datakey ) || {}; + // check for related events + if ( data.related ) + return; + // remove the stored data + $.removeData( this, drop.datakey ); + // reference the targeted element + var element = this; + // remove from the internal cache + drop.targets = $.grep( drop.targets, function( target ){ + return ( target !== element ); + }); + }, + + // shared event handler + handler: function( event, dd ){ + // local vars + var results, $targets; + // make sure the right data is available + if ( !dd ) + return; + // handle various events + switch ( event.type ){ + // draginit, from $.event.special.drag + case 'mousedown': // DROPINIT >> + case 'touchstart': // DROPINIT >> + // collect and assign the drop targets + $targets = $( drop.targets ); + if ( typeof dd.drop == "string" ) + $targets = $targets.filter( dd.drop ); + // reset drop data winner properties + $targets.each(function(){ + var data = $.data( this, drop.datakey ); + data.active = []; + data.anyactive = 0; + data.winner = 0; + }); + // set available target elements + dd.droppable = $targets; + // activate drop targets for the initial element being dragged + $special.drag.hijack( event, "dropinit", dd ); + break; + // drag, from $.event.special.drag + case 'mousemove': // TOLERATE >> + case 'touchmove': // TOLERATE >> + drop.event = event; // store the mousemove event + if ( !drop.timer ) + // monitor drop targets + drop.tolerate( dd ); + break; + // dragend, from $.event.special.drag + case 'mouseup': // DROP >> DROPEND >> + case 'touchend': // DROP >> DROPEND >> + drop.timer = clearTimeout( drop.timer ); // delete timer + if ( dd.propagates ){ + $special.drag.hijack( event, "drop", dd ); + $special.drag.hijack( event, "dropend", dd ); + } + break; + + } + }, + + // returns the location positions of an element + locate: function( elem, index ){ + var data = $.data( elem, drop.datakey ), + $elem = $( elem ), + posi = $elem.offset() || {}, + height = $elem.outerHeight(), + width = $elem.outerWidth(), + location = { + elem: elem, + width: width, + height: height, + top: posi.top, + left: posi.left, + right: posi.left + width, + bottom: posi.top + height + }; + // drag elements might not have dropdata + if ( data ){ + data.location = location; + data.index = index; + data.elem = elem; + } + return location; + }, + + // test the location positions of an element against another OR an X,Y coord + contains: function( target, test ){ // target { location } contains test [x,y] or { location } + return ( ( test[0] || test.left ) >= target.left && ( test[0] || test.right ) <= target.right + && ( test[1] || test.top ) >= target.top && ( test[1] || test.bottom ) <= target.bottom ); + }, + + // stored tolerance modes + modes: { // fn scope: "$.event.special.drop" object + // target with mouse wins, else target with most overlap wins + 'intersect': function( event, proxy, target ){ + return this.contains( target, [ event.pageX, event.pageY ] ) ? // check cursor + 1e9 : this.modes.overlap.apply( this, arguments ); // check overlap + }, + // target with most overlap wins + 'overlap': function( event, proxy, target ){ + // calculate the area of overlap... + return Math.max( 0, Math.min( target.bottom, proxy.bottom ) - Math.max( target.top, proxy.top ) ) + * Math.max( 0, Math.min( target.right, proxy.right ) - Math.max( target.left, proxy.left ) ); + }, + // proxy is completely contained within target bounds + 'fit': function( event, proxy, target ){ + return this.contains( target, proxy ) ? 1 : 0; + }, + // center of the proxy is contained within target bounds + 'middle': function( event, proxy, target ){ + return this.contains( target, [ proxy.left + proxy.width * .5, proxy.top + proxy.height * .5 ] ) ? 1 : 0; + } + }, + + // sort drop target cache by by winner (dsc), then index (asc) + sort: function( a, b ){ + return ( b.winner - a.winner ) || ( a.index - b.index ); + }, + + // async, recursive tolerance execution + tolerate: function( dd ){ + // declare local refs + var i, drp, drg, data, arr, len, elem, + // interaction iteration variables + x = 0, ia, end = dd.interactions.length, + // determine the mouse coords + xy = [ drop.event.pageX, drop.event.pageY ], + // custom or stored tolerance fn + tolerance = drop.tolerance || drop.modes[ drop.mode ]; + // go through each passed interaction... + do if ( ia = dd.interactions[x] ){ + // check valid interaction + if ( !ia ) + return; + // initialize or clear the drop data + ia.drop = []; + // holds the drop elements + arr = []; + len = ia.droppable.length; + // determine the proxy location, if needed + if ( tolerance ) + drg = drop.locate( ia.proxy ); + // reset the loop + i = 0; + // loop each stored drop target + do if ( elem = ia.droppable[i] ){ + data = $.data( elem, drop.datakey ); + drp = data.location; + if ( !drp ) continue; + // find a winner: tolerance function is defined, call it + data.winner = tolerance ? tolerance.call( drop, drop.event, drg, drp ) + // mouse position is always the fallback + : drop.contains( drp, xy ) ? 1 : 0; + arr.push( data ); + } while ( ++i < len ); // loop + // sort the drop targets + arr.sort( drop.sort ); + // reset the loop + i = 0; + // loop through all of the targets again + do if ( data = arr[ i ] ){ + // winners... + if ( data.winner && ia.drop.length < drop.multi ){ + // new winner... dropstart + if ( !data.active[x] && !data.anyactive ){ + // check to make sure that this is not prevented + if ( $special.drag.hijack( drop.event, "dropstart", dd, x, data.elem )[0] !== false ){ + data.active[x] = 1; + data.anyactive += 1; + } + // if false, it is not a winner + else + data.winner = 0; + } + // if it is still a winner + if ( data.winner ) + ia.drop.push( data.elem ); + } + // losers... + else if ( data.active[x] && data.anyactive == 1 ){ + // former winner... dropend + $special.drag.hijack( drop.event, "dropend", dd, x, data.elem ); + data.active[x] = 0; + data.anyactive -= 1; + } + } while ( ++i < len ); // loop + } while ( ++x < end ) // loop + // check if the mouse is still moving or is idle + if ( drop.last && xy[0] == drop.last.pageX && xy[1] == drop.last.pageY ) + delete drop.timer; // idle, don't recurse + else // recurse + drop.timer = setTimeout(function(){ + drop.tolerate( dd ); + }, drop.delay ); + // remember event, to compare idleness + drop.last = drop.event; + } + +}; + +// share the same special event configuration with related events... +$special.dropinit = $special.dropstart = $special.dropend = drop; + +})(jQuery); // confine scope \ No newline at end of file diff --git a/common/static/js/vendor/slick.core.js b/common/static/js/vendor/slick.core.js new file mode 100644 index 000000000000..5c4c69562a45 --- /dev/null +++ b/common/static/js/vendor/slick.core.js @@ -0,0 +1,458 @@ +/*** + * Contains core SlickGrid classes. + * @module Core + * @namespace Slick + */ + +(function ($) { + // register namespace + $.extend(true, window, { + "Slick": { + "Event": Event, + "EventData": EventData, + "EventHandler": EventHandler, + "Range": Range, + "NonDataRow": NonDataItem, + "Group": Group, + "GroupTotals": GroupTotals, + "EditorLock": EditorLock, + + /*** + * A global singleton editor lock. + * @class GlobalEditorLock + * @static + * @constructor + */ + "GlobalEditorLock": new EditorLock() + } + }); + + /*** + * An event object for passing data to event handlers and letting them control propagation. + *

This is pretty much identical to how W3C and jQuery implement events.

+ * @class EventData + * @constructor + */ + function EventData() { + var isPropagationStopped = false; + var isImmediatePropagationStopped = false; + + /*** + * Stops event from propagating up the DOM tree. + * @method stopPropagation + */ + this.stopPropagation = function () { + isPropagationStopped = true; + }; + + /*** + * Returns whether stopPropagation was called on this event object. + * @method isPropagationStopped + * @return {Boolean} + */ + this.isPropagationStopped = function () { + return isPropagationStopped; + }; + + /*** + * Prevents the rest of the handlers from being executed. + * @method stopImmediatePropagation + */ + this.stopImmediatePropagation = function () { + isImmediatePropagationStopped = true; + }; + + /*** + * Returns whether stopImmediatePropagation was called on this event object.\ + * @method isImmediatePropagationStopped + * @return {Boolean} + */ + this.isImmediatePropagationStopped = function () { + return isImmediatePropagationStopped; + } + } + + /*** + * A simple publisher-subscriber implementation. + * @class Event + * @constructor + */ + function Event() { + var handlers = []; + + /*** + * Adds an event handler to be called when the event is fired. + *

Event handler will receive two arguments - an EventData and the data + * object the event was fired with.

+ * @method subscribe + * @param fn {Function} Event handler. + */ + this.subscribe = function (fn) { + handlers.push(fn); + }; + + /*** + * Removes an event handler added with subscribe(fn). + * @method unsubscribe + * @param fn {Function} Event handler to be removed. + */ + this.unsubscribe = function (fn) { + for (var i = handlers.length - 1; i >= 0; i--) { + if (handlers[i] === fn) { + handlers.splice(i, 1); + } + } + }; + + /*** + * Fires an event notifying all subscribers. + * @method notify + * @param args {Object} Additional data object to be passed to all handlers. + * @param e {EventData} + * Optional. + * An EventData object to be passed to all handlers. + * For DOM events, an existing W3C/jQuery event object can be passed in. + * @param scope {Object} + * Optional. + * The scope ("this") within which the handler will be executed. + * If not specified, the scope will be set to the Event instance. + */ + this.notify = function (args, e, scope) { + e = e || new EventData(); + scope = scope || this; + + var returnValue; + for (var i = 0; i < handlers.length && !(e.isPropagationStopped() || e.isImmediatePropagationStopped()); i++) { + returnValue = handlers[i].call(scope, e, args); + } + + return returnValue; + }; + } + + function EventHandler() { + var handlers = []; + + this.subscribe = function (event, handler) { + handlers.push({ + event: event, + handler: handler + }); + event.subscribe(handler); + + return this; // allow chaining + }; + + this.unsubscribe = function (event, handler) { + var i = handlers.length; + while (i--) { + if (handlers[i].event === event && + handlers[i].handler === handler) { + handlers.splice(i, 1); + event.unsubscribe(handler); + return; + } + } + + return this; // allow chaining + }; + + this.unsubscribeAll = function () { + var i = handlers.length; + while (i--) { + handlers[i].event.unsubscribe(handlers[i].handler); + } + handlers = []; + + return this; // allow chaining + } + } + + /*** + * A structure containing a range of cells. + * @class Range + * @constructor + * @param fromRow {Integer} Starting row. + * @param fromCell {Integer} Starting cell. + * @param toRow {Integer} Optional. Ending row. Defaults to fromRow. + * @param toCell {Integer} Optional. Ending cell. Defaults to fromCell. + */ + function Range(fromRow, fromCell, toRow, toCell) { + if (toRow === undefined && toCell === undefined) { + toRow = fromRow; + toCell = fromCell; + } + + /*** + * @property fromRow + * @type {Integer} + */ + this.fromRow = Math.min(fromRow, toRow); + + /*** + * @property fromCell + * @type {Integer} + */ + this.fromCell = Math.min(fromCell, toCell); + + /*** + * @property toRow + * @type {Integer} + */ + this.toRow = Math.max(fromRow, toRow); + + /*** + * @property toCell + * @type {Integer} + */ + this.toCell = Math.max(fromCell, toCell); + + /*** + * Returns whether a range represents a single row. + * @method isSingleRow + * @return {Boolean} + */ + this.isSingleRow = function () { + return this.fromRow == this.toRow; + }; + + /*** + * Returns whether a range represents a single cell. + * @method isSingleCell + * @return {Boolean} + */ + this.isSingleCell = function () { + return this.fromRow == this.toRow && this.fromCell == this.toCell; + }; + + /*** + * Returns whether a range contains a given cell. + * @method contains + * @param row {Integer} + * @param cell {Integer} + * @return {Boolean} + */ + this.contains = function (row, cell) { + return row >= this.fromRow && row <= this.toRow && + cell >= this.fromCell && cell <= this.toCell; + }; + + /*** + * Returns a readable representation of a range. + * @method toString + * @return {String} + */ + this.toString = function () { + if (this.isSingleCell()) { + return "(" + this.fromRow + ":" + this.fromCell + ")"; + } + else { + return "(" + this.fromRow + ":" + this.fromCell + " - " + this.toRow + ":" + this.toCell + ")"; + } + } + } + + + /*** + * A base class that all special / non-data rows (like Group and GroupTotals) derive from. + * @class NonDataItem + * @constructor + */ + function NonDataItem() { + this.__nonDataRow = true; + } + + + /*** + * Information about a group of rows. + * @class Group + * @extends Slick.NonDataItem + * @constructor + */ + function Group() { + this.__group = true; + + /** + * Grouping level, starting with 0. + * @property level + * @type {Number} + */ + this.level = 0; + + /*** + * Number of rows in the group. + * @property count + * @type {Integer} + */ + this.count = 0; + + /*** + * Grouping value. + * @property value + * @type {Object} + */ + this.value = null; + + /*** + * Formatted display value of the group. + * @property title + * @type {String} + */ + this.title = null; + + /*** + * Whether a group is collapsed. + * @property collapsed + * @type {Boolean} + */ + this.collapsed = false; + + /*** + * GroupTotals, if any. + * @property totals + * @type {GroupTotals} + */ + this.totals = null; + + /** + * Rows that are part of the group. + * @property rows + * @type {Array} + */ + this.rows = []; + + /** + * Sub-groups that are part of the group. + * @property groups + * @type {Array} + */ + this.groups = null; + + /** + * A unique key used to identify the group. This key can be used in calls to DataView + * collapseGroup() or expandGroup(). + * @property groupingKey + * @type {Object} + */ + this.groupingKey = null; + } + + Group.prototype = new NonDataItem(); + + /*** + * Compares two Group instances. + * @method equals + * @return {Boolean} + * @param group {Group} Group instance to compare to. + */ + Group.prototype.equals = function (group) { + return this.value === group.value && + this.count === group.count && + this.collapsed === group.collapsed; + }; + + /*** + * Information about group totals. + * An instance of GroupTotals will be created for each totals row and passed to the aggregators + * so that they can store arbitrary data in it. That data can later be accessed by group totals + * formatters during the display. + * @class GroupTotals + * @extends Slick.NonDataItem + * @constructor + */ + function GroupTotals() { + this.__groupTotals = true; + + /*** + * Parent Group. + * @param group + * @type {Group} + */ + this.group = null; + } + + GroupTotals.prototype = new NonDataItem(); + + /*** + * A locking helper to track the active edit controller and ensure that only a single controller + * can be active at a time. This prevents a whole class of state and validation synchronization + * issues. An edit controller (such as SlickGrid) can query if an active edit is in progress + * and attempt a commit or cancel before proceeding. + * @class EditorLock + * @constructor + */ + function EditorLock() { + var activeEditController = null; + + /*** + * Returns true if a specified edit controller is active (has the edit lock). + * If the parameter is not specified, returns true if any edit controller is active. + * @method isActive + * @param editController {EditController} + * @return {Boolean} + */ + this.isActive = function (editController) { + return (editController ? activeEditController === editController : activeEditController !== null); + }; + + /*** + * Sets the specified edit controller as the active edit controller (acquire edit lock). + * If another edit controller is already active, and exception will be thrown. + * @method activate + * @param editController {EditController} edit controller acquiring the lock + */ + this.activate = function (editController) { + if (editController === activeEditController) { // already activated? + return; + } + if (activeEditController !== null) { + throw "SlickGrid.EditorLock.activate: an editController is still active, can't activate another editController"; + } + if (!editController.commitCurrentEdit) { + throw "SlickGrid.EditorLock.activate: editController must implement .commitCurrentEdit()"; + } + if (!editController.cancelCurrentEdit) { + throw "SlickGrid.EditorLock.activate: editController must implement .cancelCurrentEdit()"; + } + activeEditController = editController; + }; + + /*** + * Unsets the specified edit controller as the active edit controller (release edit lock). + * If the specified edit controller is not the active one, an exception will be thrown. + * @method deactivate + * @param editController {EditController} edit controller releasing the lock + */ + this.deactivate = function (editController) { + if (activeEditController !== editController) { + throw "SlickGrid.EditorLock.deactivate: specified editController is not the currently active one"; + } + activeEditController = null; + }; + + /*** + * Attempts to commit the current edit by calling "commitCurrentEdit" method on the active edit + * controller and returns whether the commit attempt was successful (commit may fail due to validation + * errors, etc.). Edit controller's "commitCurrentEdit" must return true if the commit has succeeded + * and false otherwise. If no edit controller is active, returns true. + * @method commitCurrentEdit + * @return {Boolean} + */ + this.commitCurrentEdit = function () { + return (activeEditController ? activeEditController.commitCurrentEdit() : true); + }; + + /*** + * Attempts to cancel the current edit by calling "cancelCurrentEdit" method on the active edit + * controller and returns whether the edit was successfully cancelled. If no edit controller is + * active, returns true. + * @method cancelCurrentEdit + * @return {Boolean} + */ + this.cancelCurrentEdit = function cancelCurrentEdit() { + return (activeEditController ? activeEditController.cancelCurrentEdit() : true); + }; + } +})(jQuery); + + diff --git a/common/static/js/vendor/slick.dataview.js b/common/static/js/vendor/slick.dataview.js new file mode 100644 index 000000000000..bbf2d1c3ac27 --- /dev/null +++ b/common/static/js/vendor/slick.dataview.js @@ -0,0 +1,1063 @@ +(function ($) { + $.extend(true, window, { + Slick: { + Data: { + DataView: DataView, + Aggregators: { + Avg: AvgAggregator, + Min: MinAggregator, + Max: MaxAggregator, + Sum: SumAggregator + } + } + } + }); + + + /*** + * A sample Model implementation. + * Provides a filtered view of the underlying data. + * + * Relies on the data item having an "id" property uniquely identifying it. + */ + function DataView(options) { + var self = this; + + var defaults = { + groupItemMetadataProvider: null, + inlineFilters: false + }; + + + // private + var idProperty = "id"; // property holding a unique row id + var items = []; // data by index + var rows = []; // data by row + var idxById = {}; // indexes by id + var rowsById = null; // rows by id; lazy-calculated + var filter = null; // filter function + var updated = null; // updated item ids + var suspend = false; // suspends the recalculation + var sortAsc = true; + var fastSortField; + var sortComparer; + var refreshHints = {}; + var prevRefreshHints = {}; + var filterArgs; + var filteredItems = []; + var compiledFilter; + var compiledFilterWithCaching; + var filterCache = []; + + // grouping + var groupingInfoDefaults = { + getter: null, + formatter: null, + comparer: function(a, b) { return a.value - b.value; }, + predefinedValues: [], + aggregators: [], + aggregateEmpty: false, + aggregateCollapsed: false, + aggregateChildGroups: false, + collapsed: false, + displayTotalsRow: true + }; + var groupingInfos = []; + var groups = []; + var toggledGroupsByLevel = []; + var groupingDelimiter = ':|:'; + + var pagesize = 0; + var pagenum = 0; + var totalRows = 0; + + // events + var onRowCountChanged = new Slick.Event(); + var onRowsChanged = new Slick.Event(); + var onPagingInfoChanged = new Slick.Event(); + + options = $.extend(true, {}, defaults, options); + + + function beginUpdate() { + suspend = true; + } + + function endUpdate() { + suspend = false; + refresh(); + } + + function setRefreshHints(hints) { + refreshHints = hints; + } + + function setFilterArgs(args) { + filterArgs = args; + } + + function updateIdxById(startingIndex) { + startingIndex = startingIndex || 0; + var id; + for (var i = startingIndex, l = items.length; i < l; i++) { + id = items[i][idProperty]; + if (id === undefined) { + throw "Each data element must implement a unique 'id' property"; + } + idxById[id] = i; + } + } + + function ensureIdUniqueness() { + var id; + for (var i = 0, l = items.length; i < l; i++) { + id = items[i][idProperty]; + if (id === undefined || idxById[id] !== i) { + throw "Each data element must implement a unique 'id' property"; + } + } + } + + function getItems() { + return items; + } + + function setItems(data, objectIdProperty) { + if (objectIdProperty !== undefined) { + idProperty = objectIdProperty; + } + items = filteredItems = data; + idxById = {}; + updateIdxById(); + ensureIdUniqueness(); + refresh(); + } + + function setPagingOptions(args) { + if (args.pageSize != undefined) { + pagesize = args.pageSize; + pagenum = pagesize ? Math.min(pagenum, Math.max(0, Math.ceil(totalRows / pagesize) - 1)) : 0; + } + + if (args.pageNum != undefined) { + pagenum = Math.min(args.pageNum, Math.max(0, Math.ceil(totalRows / pagesize) - 1)); + } + + onPagingInfoChanged.notify(getPagingInfo(), null, self); + + refresh(); + } + + function getPagingInfo() { + var totalPages = pagesize ? Math.max(1, Math.ceil(totalRows / pagesize)) : 1; + return {pageSize: pagesize, pageNum: pagenum, totalRows: totalRows, totalPages: totalPages}; + } + + function sort(comparer, ascending) { + sortAsc = ascending; + sortComparer = comparer; + fastSortField = null; + if (ascending === false) { + items.reverse(); + } + items.sort(comparer); + if (ascending === false) { + items.reverse(); + } + idxById = {}; + updateIdxById(); + refresh(); + } + + /*** + * Provides a workaround for the extremely slow sorting in IE. + * Does a [lexicographic] sort on a give column by temporarily overriding Object.prototype.toString + * to return the value of that field and then doing a native Array.sort(). + */ + function fastSort(field, ascending) { + sortAsc = ascending; + fastSortField = field; + sortComparer = null; + var oldToString = Object.prototype.toString; + Object.prototype.toString = (typeof field == "function") ? field : function () { + return this[field] + }; + // an extra reversal for descending sort keeps the sort stable + // (assuming a stable native sort implementation, which isn't true in some cases) + if (ascending === false) { + items.reverse(); + } + items.sort(); + Object.prototype.toString = oldToString; + if (ascending === false) { + items.reverse(); + } + idxById = {}; + updateIdxById(); + refresh(); + } + + function reSort() { + if (sortComparer) { + sort(sortComparer, sortAsc); + } else if (fastSortField) { + fastSort(fastSortField, sortAsc); + } + } + + function setFilter(filterFn) { + filter = filterFn; + if (options.inlineFilters) { + compiledFilter = compileFilter(); + compiledFilterWithCaching = compileFilterWithCaching(); + } + refresh(); + } + + function getGrouping() { + return groupingInfos; + } + + function setGrouping(groupingInfo) { + if (!options.groupItemMetadataProvider) { + options.groupItemMetadataProvider = new Slick.Data.GroupItemMetadataProvider(); + } + + groups = []; + toggledGroupsByLevel = []; + groupingInfo = groupingInfo || []; + groupingInfos = (groupingInfo instanceof Array) ? groupingInfo : [groupingInfo]; + + for (var i = 0; i < groupingInfos.length; i++) { + var gi = groupingInfos[i] = $.extend(true, {}, groupingInfoDefaults, groupingInfos[i]); + gi.getterIsAFn = typeof gi.getter === "function"; + + // pre-compile accumulator loops + gi.compiledAccumulators = []; + var idx = gi.aggregators.length; + while (idx--) { + gi.compiledAccumulators[idx] = compileAccumulatorLoop(gi.aggregators[idx]); + } + + toggledGroupsByLevel[i] = {}; + } + + refresh(); + } + + /** + * @deprecated Please use {@link setGrouping}. + */ + function groupBy(valueGetter, valueFormatter, sortComparer) { + if (valueGetter == null) { + setGrouping([]); + return; + } + + setGrouping({ + getter: valueGetter, + formatter: valueFormatter, + comparer: sortComparer + }); + } + + /** + * @deprecated Please use {@link setGrouping}. + */ + function setAggregators(groupAggregators, includeCollapsed) { + if (!groupingInfos.length) { + throw new Error("At least one grouping must be specified before calling setAggregators()."); + } + + groupingInfos[0].aggregators = groupAggregators; + groupingInfos[0].aggregateCollapsed = includeCollapsed; + + setGrouping(groupingInfos); + } + + function getItemByIdx(i) { + return items[i]; + } + + function getIdxById(id) { + return idxById[id]; + } + + function ensureRowsByIdCache() { + if (!rowsById) { + rowsById = {}; + for (var i = 0, l = rows.length; i < l; i++) { + rowsById[rows[i][idProperty]] = i; + } + } + } + + function getRowById(id) { + ensureRowsByIdCache(); + return rowsById[id]; + } + + function getItemById(id) { + return items[idxById[id]]; + } + + function mapIdsToRows(idArray) { + var rows = []; + ensureRowsByIdCache(); + for (var i = 0; i < idArray.length; i++) { + var row = rowsById[idArray[i]]; + if (row != null) { + rows[rows.length] = row; + } + } + return rows; + } + + function mapRowsToIds(rowArray) { + var ids = []; + for (var i = 0; i < rowArray.length; i++) { + if (rowArray[i] < rows.length) { + ids[ids.length] = rows[rowArray[i]][idProperty]; + } + } + return ids; + } + + function updateItem(id, item) { + if (idxById[id] === undefined || id !== item[idProperty]) { + throw "Invalid or non-matching id"; + } + items[idxById[id]] = item; + if (!updated) { + updated = {}; + } + updated[id] = true; + refresh(); + } + + function insertItem(insertBefore, item) { + items.splice(insertBefore, 0, item); + updateIdxById(insertBefore); + refresh(); + } + + function addItem(item) { + items.push(item); + updateIdxById(items.length - 1); + refresh(); + } + + function deleteItem(id) { + var idx = idxById[id]; + if (idx === undefined) { + throw "Invalid id"; + } + delete idxById[id]; + items.splice(idx, 1); + updateIdxById(idx); + refresh(); + } + + function getLength() { + return rows.length; + } + + function getItem(i) { + return rows[i]; + } + + function getItemMetadata(i) { + var item = rows[i]; + if (item === undefined) { + return null; + } + + // overrides for grouping rows + if (item.__group) { + return options.groupItemMetadataProvider.getGroupRowMetadata(item); + } + + // overrides for totals rows + if (item.__groupTotals) { + return options.groupItemMetadataProvider.getTotalsRowMetadata(item); + } + + return null; + } + + function expandCollapseAllGroups(level, collapse) { + if (level == null) { + for (var i = 0; i < groupingInfos.length; i++) { + toggledGroupsByLevel[i] = {}; + groupingInfos[i].collapsed = collapse; + } + } else { + toggledGroupsByLevel[level] = {}; + groupingInfos[level].collapsed = collapse; + } + refresh(); + } + + /** + * @param level {Number} Optional level to collapse. If not specified, applies to all levels. + */ + function collapseAllGroups(level) { + expandCollapseAllGroups(level, true); + } + + /** + * @param level {Number} Optional level to expand. If not specified, applies to all levels. + */ + function expandAllGroups(level) { + expandCollapseAllGroups(level, false); + } + + function expandCollapseGroup(level, groupingKey, collapse) { + toggledGroupsByLevel[level][groupingKey] = groupingInfos[level].collapsed ^ collapse; + refresh(); + } + + /** + * @param varArgs Either a Slick.Group's "groupingKey" property, or a + * variable argument list of grouping values denoting a unique path to the row. For + * example, calling collapseGroup('high', '10%') will collapse the '10%' subgroup of + * the 'high' setGrouping. + */ + function collapseGroup(varArgs) { + var args = Array.prototype.slice.call(arguments); + var arg0 = args[0]; + if (args.length == 1 && arg0.indexOf(groupingDelimiter) != -1) { + expandCollapseGroup(arg0.split(groupingDelimiter).length - 1, arg0, true); + } else { + expandCollapseGroup(args.length - 1, args.join(groupingDelimiter), true); + } + } + + /** + * @param varArgs Either a Slick.Group's "groupingKey" property, or a + * variable argument list of grouping values denoting a unique path to the row. For + * example, calling expandGroup('high', '10%') will expand the '10%' subgroup of + * the 'high' setGrouping. + */ + function expandGroup(varArgs) { + var args = Array.prototype.slice.call(arguments); + var arg0 = args[0]; + if (args.length == 1 && arg0.indexOf(groupingDelimiter) != -1) { + expandCollapseGroup(arg0.split(groupingDelimiter).length - 1, arg0, false); + } else { + expandCollapseGroup(args.length - 1, args.join(groupingDelimiter), false); + } + } + + function getGroups() { + return groups; + } + + function extractGroups(rows, parentGroup) { + var group; + var val; + var groups = []; + var groupsByVal = []; + var r; + var level = parentGroup ? parentGroup.level + 1 : 0; + var gi = groupingInfos[level]; + + for (var i = 0, l = gi.predefinedValues.length; i < l; i++) { + val = gi.predefinedValues[i]; + group = groupsByVal[val]; + if (!group) { + group = new Slick.Group(); + group.value = val; + group.level = level; + group.groupingKey = (parentGroup ? parentGroup.groupingKey + groupingDelimiter : '') + val; + groups[groups.length] = group; + groupsByVal[val] = group; + } + } + + for (var i = 0, l = rows.length; i < l; i++) { + r = rows[i]; + val = gi.getterIsAFn ? gi.getter(r) : r[gi.getter]; + group = groupsByVal[val]; + if (!group) { + group = new Slick.Group(); + group.value = val; + group.level = level; + group.groupingKey = (parentGroup ? parentGroup.groupingKey + groupingDelimiter : '') + val; + groups[groups.length] = group; + groupsByVal[val] = group; + } + + group.rows[group.count++] = r; + } + + if (level < groupingInfos.length - 1) { + for (var i = 0; i < groups.length; i++) { + group = groups[i]; + group.groups = extractGroups(group.rows, group); + } + } + + groups.sort(groupingInfos[level].comparer); + + return groups; + } + + // TODO: lazy totals calculation + function calculateGroupTotals(group) { + // TODO: try moving iterating over groups into compiled accumulator + var gi = groupingInfos[group.level]; + var isLeafLevel = (group.level == groupingInfos.length); + var totals = new Slick.GroupTotals(); + var agg, idx = gi.aggregators.length; + while (idx--) { + agg = gi.aggregators[idx]; + agg.init(); + gi.compiledAccumulators[idx].call(agg, + (!isLeafLevel && gi.aggregateChildGroups) ? group.groups : group.rows); + agg.storeResult(totals); + } + totals.group = group; + group.totals = totals; + } + + function calculateTotals(groups, level) { + level = level || 0; + var gi = groupingInfos[level]; + var idx = groups.length, g; + while (idx--) { + g = groups[idx]; + + if (g.collapsed && !gi.aggregateCollapsed) { + continue; + } + + // Do a depth-first aggregation so that parent setGrouping aggregators can access subgroup totals. + if (g.groups) { + calculateTotals(g.groups, level + 1); + } + + if (gi.aggregators.length && ( + gi.aggregateEmpty || g.rows.length || (g.groups && g.groups.length))) { + calculateGroupTotals(g); + } + } + } + + function finalizeGroups(groups, level) { + level = level || 0; + var gi = groupingInfos[level]; + var groupCollapsed = gi.collapsed; + var toggledGroups = toggledGroupsByLevel[level]; + var idx = groups.length, g; + while (idx--) { + g = groups[idx]; + g.collapsed = groupCollapsed ^ toggledGroups[g.groupingKey]; + g.title = gi.formatter ? gi.formatter(g) : g.value; + + if (g.groups) { + finalizeGroups(g.groups, level + 1); + // Let the non-leaf setGrouping rows get garbage-collected. + // They may have been used by aggregates that go over all of the descendants, + // but at this point they are no longer needed. + g.rows = []; + } + } + } + + function flattenGroupedRows(groups, level) { + level = level || 0; + var gi = groupingInfos[level]; + var groupedRows = [], rows, gl = 0, g; + for (var i = 0, l = groups.length; i < l; i++) { + g = groups[i]; + groupedRows[gl++] = g; + + if (!g.collapsed) { + rows = g.groups ? flattenGroupedRows(g.groups, level + 1) : g.rows; + for (var j = 0, jj = rows.length; j < jj; j++) { + groupedRows[gl++] = rows[j]; + } + } + + if (g.totals && gi.displayTotalsRow && (!g.collapsed || gi.aggregateCollapsed)) { + groupedRows[gl++] = g.totals; + } + } + return groupedRows; + } + + function getFunctionInfo(fn) { + var fnRegex = /^function[^(]*\(([^)]*)\)\s*{([\s\S]*)}$/; + var matches = fn.toString().match(fnRegex); + return { + params: matches[1].split(","), + body: matches[2] + }; + } + + function compileAccumulatorLoop(aggregator) { + var accumulatorInfo = getFunctionInfo(aggregator.accumulate); + var fn = new Function( + "_items", + "for (var " + accumulatorInfo.params[0] + ", _i=0, _il=_items.length; _i<_il; _i++) {" + + accumulatorInfo.params[0] + " = _items[_i]; " + + accumulatorInfo.body + + "}" + ); + fn.displayName = fn.name = "compiledAccumulatorLoop"; + return fn; + } + + function compileFilter() { + var filterInfo = getFunctionInfo(filter); + + var filterBody = filterInfo.body + .replace(/return false[;}]/gi, "{ continue _coreloop; }") + .replace(/return true[;}]/gi, "{ _retval[_idx++] = $item$; continue _coreloop; }") + .replace(/return ([^;}]+?);/gi, + "{ if ($1) { _retval[_idx++] = $item$; }; continue _coreloop; }"); + + // This preserves the function template code after JS compression, + // so that replace() commands still work as expected. + var tpl = [ + //"function(_items, _args) { ", + "var _retval = [], _idx = 0; ", + "var $item$, $args$ = _args; ", + "_coreloop: ", + "for (var _i = 0, _il = _items.length; _i < _il; _i++) { ", + "$item$ = _items[_i]; ", + "$filter$; ", + "} ", + "return _retval; " + //"}" + ].join(""); + tpl = tpl.replace(/\$filter\$/gi, filterBody); + tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]); + tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]); + + var fn = new Function("_items,_args", tpl); + fn.displayName = fn.name = "compiledFilter"; + return fn; + } + + function compileFilterWithCaching() { + var filterInfo = getFunctionInfo(filter); + + var filterBody = filterInfo.body + .replace(/return false[;}]/gi, "{ continue _coreloop; }") + .replace(/return true[;}]/gi, "{ _cache[_i] = true;_retval[_idx++] = $item$; continue _coreloop; }") + .replace(/return ([^;}]+?);/gi, + "{ if ((_cache[_i] = $1)) { _retval[_idx++] = $item$; }; continue _coreloop; }"); + + // This preserves the function template code after JS compression, + // so that replace() commands still work as expected. + var tpl = [ + //"function(_items, _args, _cache) { ", + "var _retval = [], _idx = 0; ", + "var $item$, $args$ = _args; ", + "_coreloop: ", + "for (var _i = 0, _il = _items.length; _i < _il; _i++) { ", + "$item$ = _items[_i]; ", + "if (_cache[_i]) { ", + "_retval[_idx++] = $item$; ", + "continue _coreloop; ", + "} ", + "$filter$; ", + "} ", + "return _retval; " + //"}" + ].join(""); + tpl = tpl.replace(/\$filter\$/gi, filterBody); + tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]); + tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]); + + var fn = new Function("_items,_args,_cache", tpl); + fn.displayName = fn.name = "compiledFilterWithCaching"; + return fn; + } + + function uncompiledFilter(items, args) { + var retval = [], idx = 0; + + for (var i = 0, ii = items.length; i < ii; i++) { + if (filter(items[i], args)) { + retval[idx++] = items[i]; + } + } + + return retval; + } + + function uncompiledFilterWithCaching(items, args, cache) { + var retval = [], idx = 0, item; + + for (var i = 0, ii = items.length; i < ii; i++) { + item = items[i]; + if (cache[i]) { + retval[idx++] = item; + } else if (filter(item, args)) { + retval[idx++] = item; + cache[i] = true; + } + } + + return retval; + } + + function getFilteredAndPagedItems(items) { + if (filter) { + var batchFilter = options.inlineFilters ? compiledFilter : uncompiledFilter; + var batchFilterWithCaching = options.inlineFilters ? compiledFilterWithCaching : uncompiledFilterWithCaching; + + if (refreshHints.isFilterNarrowing) { + filteredItems = batchFilter(filteredItems, filterArgs); + } else if (refreshHints.isFilterExpanding) { + filteredItems = batchFilterWithCaching(items, filterArgs, filterCache); + } else if (!refreshHints.isFilterUnchanged) { + filteredItems = batchFilter(items, filterArgs); + } + } else { + // special case: if not filtering and not paging, the resulting + // rows collection needs to be a copy so that changes due to sort + // can be caught + filteredItems = pagesize ? items : items.concat(); + } + + // get the current page + var paged; + if (pagesize) { + if (filteredItems.length < pagenum * pagesize) { + pagenum = Math.floor(filteredItems.length / pagesize); + } + paged = filteredItems.slice(pagesize * pagenum, pagesize * pagenum + pagesize); + } else { + paged = filteredItems; + } + + return {totalRows: filteredItems.length, rows: paged}; + } + + function getRowDiffs(rows, newRows) { + var item, r, eitherIsNonData, diff = []; + var from = 0, to = newRows.length; + + if (refreshHints && refreshHints.ignoreDiffsBefore) { + from = Math.max(0, + Math.min(newRows.length, refreshHints.ignoreDiffsBefore)); + } + + if (refreshHints && refreshHints.ignoreDiffsAfter) { + to = Math.min(newRows.length, + Math.max(0, refreshHints.ignoreDiffsAfter)); + } + + for (var i = from, rl = rows.length; i < to; i++) { + if (i >= rl) { + diff[diff.length] = i; + } else { + item = newRows[i]; + r = rows[i]; + + if ((groupingInfos.length && (eitherIsNonData = (item.__nonDataRow) || (r.__nonDataRow)) && + item.__group !== r.__group || + item.__group && !item.equals(r)) + || (eitherIsNonData && + // no good way to compare totals since they are arbitrary DTOs + // deep object comparison is pretty expensive + // always considering them 'dirty' seems easier for the time being + (item.__groupTotals || r.__groupTotals)) + || item[idProperty] != r[idProperty] + || (updated && updated[item[idProperty]]) + ) { + diff[diff.length] = i; + } + } + } + return diff; + } + + function recalc(_items) { + rowsById = null; + + if (refreshHints.isFilterNarrowing != prevRefreshHints.isFilterNarrowing || + refreshHints.isFilterExpanding != prevRefreshHints.isFilterExpanding) { + filterCache = []; + } + + var filteredItems = getFilteredAndPagedItems(_items); + totalRows = filteredItems.totalRows; + var newRows = filteredItems.rows; + + groups = []; + if (groupingInfos.length) { + groups = extractGroups(newRows); + if (groups.length) { + calculateTotals(groups); + finalizeGroups(groups); + newRows = flattenGroupedRows(groups); + } + } + + var diff = getRowDiffs(rows, newRows); + + rows = newRows; + + return diff; + } + + function refresh() { + if (suspend) { + return; + } + + var countBefore = rows.length; + var totalRowsBefore = totalRows; + + var diff = recalc(items, filter); // pass as direct refs to avoid closure perf hit + + // if the current page is no longer valid, go to last page and recalc + // we suffer a performance penalty here, but the main loop (recalc) remains highly optimized + if (pagesize && totalRows < pagenum * pagesize) { + pagenum = Math.max(0, Math.ceil(totalRows / pagesize) - 1); + diff = recalc(items, filter); + } + + updated = null; + prevRefreshHints = refreshHints; + refreshHints = {}; + + if (totalRowsBefore != totalRows) { + onPagingInfoChanged.notify(getPagingInfo(), null, self); + } + if (countBefore != rows.length) { + onRowCountChanged.notify({previous: countBefore, current: rows.length}, null, self); + } + if (diff.length > 0) { + onRowsChanged.notify({rows: diff}, null, self); + } + } + + function syncGridSelection(grid, preserveHidden) { + var self = this; + var selectedRowIds = self.mapRowsToIds(grid.getSelectedRows());; + var inHandler; + + function update() { + if (selectedRowIds.length > 0) { + inHandler = true; + var selectedRows = self.mapIdsToRows(selectedRowIds); + if (!preserveHidden) { + selectedRowIds = self.mapRowsToIds(selectedRows); + } + grid.setSelectedRows(selectedRows); + inHandler = false; + } + } + + grid.onSelectedRowsChanged.subscribe(function(e, args) { + if (inHandler) { return; } + selectedRowIds = self.mapRowsToIds(grid.getSelectedRows()); + }); + + this.onRowsChanged.subscribe(update); + + this.onRowCountChanged.subscribe(update); + } + + function syncGridCellCssStyles(grid, key) { + var hashById; + var inHandler; + + // since this method can be called after the cell styles have been set, + // get the existing ones right away + storeCellCssStyles(grid.getCellCssStyles(key)); + + function storeCellCssStyles(hash) { + hashById = {}; + for (var row in hash) { + var id = rows[row][idProperty]; + hashById[id] = hash[row]; + } + } + + function update() { + if (hashById) { + inHandler = true; + ensureRowsByIdCache(); + var newHash = {}; + for (var id in hashById) { + var row = rowsById[id]; + if (row != undefined) { + newHash[row] = hashById[id]; + } + } + grid.setCellCssStyles(key, newHash); + inHandler = false; + } + } + + grid.onCellCssStylesChanged.subscribe(function(e, args) { + if (inHandler) { return; } + if (key != args.key) { return; } + if (args.hash) { + storeCellCssStyles(args.hash); + } + }); + + this.onRowsChanged.subscribe(update); + + this.onRowCountChanged.subscribe(update); + } + + return { + // methods + "beginUpdate": beginUpdate, + "endUpdate": endUpdate, + "setPagingOptions": setPagingOptions, + "getPagingInfo": getPagingInfo, + "getItems": getItems, + "setItems": setItems, + "setFilter": setFilter, + "sort": sort, + "fastSort": fastSort, + "reSort": reSort, + "setGrouping": setGrouping, + "getGrouping": getGrouping, + "groupBy": groupBy, + "setAggregators": setAggregators, + "collapseAllGroups": collapseAllGroups, + "expandAllGroups": expandAllGroups, + "collapseGroup": collapseGroup, + "expandGroup": expandGroup, + "getGroups": getGroups, + "getIdxById": getIdxById, + "getRowById": getRowById, + "getItemById": getItemById, + "getItemByIdx": getItemByIdx, + "mapRowsToIds": mapRowsToIds, + "mapIdsToRows": mapIdsToRows, + "setRefreshHints": setRefreshHints, + "setFilterArgs": setFilterArgs, + "refresh": refresh, + "updateItem": updateItem, + "insertItem": insertItem, + "addItem": addItem, + "deleteItem": deleteItem, + "syncGridSelection": syncGridSelection, + "syncGridCellCssStyles": syncGridCellCssStyles, + + // data provider methods + "getLength": getLength, + "getItem": getItem, + "getItemMetadata": getItemMetadata, + + // events + "onRowCountChanged": onRowCountChanged, + "onRowsChanged": onRowsChanged, + "onPagingInfoChanged": onPagingInfoChanged + }; + } + + function AvgAggregator(field) { + this.field_ = field; + + this.init = function () { + this.count_ = 0; + this.nonNullCount_ = 0; + this.sum_ = 0; + }; + + this.accumulate = function (item) { + var val = item[this.field_]; + this.count_++; + if (val != null && val !== "" && val !== NaN) { + this.nonNullCount_++; + this.sum_ += parseFloat(val); + } + }; + + this.storeResult = function (groupTotals) { + if (!groupTotals.avg) { + groupTotals.avg = {}; + } + if (this.nonNullCount_ != 0) { + groupTotals.avg[this.field_] = this.sum_ / this.nonNullCount_; + } + }; + } + + function MinAggregator(field) { + this.field_ = field; + + this.init = function () { + this.min_ = null; + }; + + this.accumulate = function (item) { + var val = item[this.field_]; + if (val != null && val !== "" && val !== NaN) { + if (this.min_ == null || val < this.min_) { + this.min_ = val; + } + } + }; + + this.storeResult = function (groupTotals) { + if (!groupTotals.min) { + groupTotals.min = {}; + } + groupTotals.min[this.field_] = this.min_; + } + } + + function MaxAggregator(field) { + this.field_ = field; + + this.init = function () { + this.max_ = null; + }; + + this.accumulate = function (item) { + var val = item[this.field_]; + if (val != null && val !== "" && val !== NaN) { + if (this.max_ == null || val > this.max_) { + this.max_ = val; + } + } + }; + + this.storeResult = function (groupTotals) { + if (!groupTotals.max) { + groupTotals.max = {}; + } + groupTotals.max[this.field_] = this.max_; + } + } + + function SumAggregator(field) { + this.field_ = field; + + this.init = function () { + this.sum_ = null; + }; + + this.accumulate = function (item) { + var val = item[this.field_]; + if (val != null && val !== "" && val !== NaN) { + this.sum_ += parseFloat(val); + } + }; + + this.storeResult = function (groupTotals) { + if (!groupTotals.sum) { + groupTotals.sum = {}; + } + groupTotals.sum[this.field_] = this.sum_; + } + } + + // TODO: add more built-in aggregators + // TODO: merge common aggregators in one to prevent needles iterating + +})(jQuery); diff --git a/common/static/js/vendor/slick.editors.js b/common/static/js/vendor/slick.editors.js new file mode 100644 index 000000000000..f3ef8e9d2845 --- /dev/null +++ b/common/static/js/vendor/slick.editors.js @@ -0,0 +1,512 @@ +/*** + * Contains basic SlickGrid editors. + * @module Editors + * @namespace Slick + */ + +(function ($) { + // register namespace + $.extend(true, window, { + "Slick": { + "Editors": { + "Text": TextEditor, + "Integer": IntegerEditor, + "Date": DateEditor, + "YesNoSelect": YesNoSelectEditor, + "Checkbox": CheckboxEditor, + "PercentComplete": PercentCompleteEditor, + "LongText": LongTextEditor + } + } + }); + + function TextEditor(args) { + var $input; + var defaultValue; + var scope = this; + + this.init = function () { + $input = $("") + .appendTo(args.container) + .bind("keydown.nav", function (e) { + if (e.keyCode === $.ui.keyCode.LEFT || e.keyCode === $.ui.keyCode.RIGHT) { + e.stopImmediatePropagation(); + } + }) + .focus() + .select(); + }; + + this.destroy = function () { + $input.remove(); + }; + + this.focus = function () { + $input.focus(); + }; + + this.getValue = function () { + return $input.val(); + }; + + this.setValue = function (val) { + $input.val(val); + }; + + this.loadValue = function (item) { + defaultValue = item[args.column.field] || ""; + $input.val(defaultValue); + $input[0].defaultValue = defaultValue; + $input.select(); + }; + + this.serializeValue = function () { + return $input.val(); + }; + + this.applyValue = function (item, state) { + item[args.column.field] = state; + }; + + this.isValueChanged = function () { + return (!($input.val() == "" && defaultValue == null)) && ($input.val() != defaultValue); + }; + + this.validate = function () { + if (args.column.validator) { + var validationResults = args.column.validator($input.val()); + if (!validationResults.valid) { + return validationResults; + } + } + + return { + valid: true, + msg: null + }; + }; + + this.init(); + } + + function IntegerEditor(args) { + var $input; + var defaultValue; + var scope = this; + + this.init = function () { + $input = $(""); + + $input.bind("keydown.nav", function (e) { + if (e.keyCode === $.ui.keyCode.LEFT || e.keyCode === $.ui.keyCode.RIGHT) { + e.stopImmediatePropagation(); + } + }); + + $input.appendTo(args.container); + $input.focus().select(); + }; + + this.destroy = function () { + $input.remove(); + }; + + this.focus = function () { + $input.focus(); + }; + + this.loadValue = function (item) { + defaultValue = item[args.column.field]; + $input.val(defaultValue); + $input[0].defaultValue = defaultValue; + $input.select(); + }; + + this.serializeValue = function () { + return parseInt($input.val(), 10) || 0; + }; + + this.applyValue = function (item, state) { + item[args.column.field] = state; + }; + + this.isValueChanged = function () { + return (!($input.val() == "" && defaultValue == null)) && ($input.val() != defaultValue); + }; + + this.validate = function () { + if (isNaN($input.val())) { + return { + valid: false, + msg: "Please enter a valid integer" + }; + } + + return { + valid: true, + msg: null + }; + }; + + this.init(); + } + + function DateEditor(args) { + var $input; + var defaultValue; + var scope = this; + var calendarOpen = false; + + this.init = function () { + $input = $(""); + $input.appendTo(args.container); + $input.focus().select(); + $input.datepicker({ + showOn: "button", + buttonImageOnly: true, + buttonImage: "../images/calendar.gif", + beforeShow: function () { + calendarOpen = true + }, + onClose: function () { + calendarOpen = false + } + }); + $input.width($input.width() - 18); + }; + + this.destroy = function () { + $.datepicker.dpDiv.stop(true, true); + $input.datepicker("hide"); + $input.datepicker("destroy"); + $input.remove(); + }; + + this.show = function () { + if (calendarOpen) { + $.datepicker.dpDiv.stop(true, true).show(); + } + }; + + this.hide = function () { + if (calendarOpen) { + $.datepicker.dpDiv.stop(true, true).hide(); + } + }; + + this.position = function (position) { + if (!calendarOpen) { + return; + } + $.datepicker.dpDiv + .css("top", position.top + 30) + .css("left", position.left); + }; + + this.focus = function () { + $input.focus(); + }; + + this.loadValue = function (item) { + defaultValue = item[args.column.field]; + $input.val(defaultValue); + $input[0].defaultValue = defaultValue; + $input.select(); + }; + + this.serializeValue = function () { + return $input.val(); + }; + + this.applyValue = function (item, state) { + item[args.column.field] = state; + }; + + this.isValueChanged = function () { + return (!($input.val() == "" && defaultValue == null)) && ($input.val() != defaultValue); + }; + + this.validate = function () { + return { + valid: true, + msg: null + }; + }; + + this.init(); + } + + function YesNoSelectEditor(args) { + var $select; + var defaultValue; + var scope = this; + + this.init = function () { + $select = $(""); + $select.appendTo(args.container); + $select.focus(); + }; + + this.destroy = function () { + $select.remove(); + }; + + this.focus = function () { + $select.focus(); + }; + + this.loadValue = function (item) { + $select.val((defaultValue = item[args.column.field]) ? "yes" : "no"); + $select.select(); + }; + + this.serializeValue = function () { + return ($select.val() == "yes"); + }; + + this.applyValue = function (item, state) { + item[args.column.field] = state; + }; + + this.isValueChanged = function () { + return ($select.val() != defaultValue); + }; + + this.validate = function () { + return { + valid: true, + msg: null + }; + }; + + this.init(); + } + + function CheckboxEditor(args) { + var $select; + var defaultValue; + var scope = this; + + this.init = function () { + $select = $(""); + $select.appendTo(args.container); + $select.focus(); + }; + + this.destroy = function () { + $select.remove(); + }; + + this.focus = function () { + $select.focus(); + }; + + this.loadValue = function (item) { + defaultValue = !!item[args.column.field]; + if (defaultValue) { + $select.attr("checked", "checked"); + } else { + $select.removeAttr("checked"); + } + }; + + this.serializeValue = function () { + return !!$select.attr("checked"); + }; + + this.applyValue = function (item, state) { + item[args.column.field] = state; + }; + + this.isValueChanged = function () { + return (this.serializeValue() !== defaultValue); + }; + + this.validate = function () { + return { + valid: true, + msg: null + }; + }; + + this.init(); + } + + function PercentCompleteEditor(args) { + var $input, $picker; + var defaultValue; + var scope = this; + + this.init = function () { + $input = $(""); + $input.width($(args.container).innerWidth() - 25); + $input.appendTo(args.container); + + $picker = $("

").appendTo(args.container); + $picker.append("
"); + + $picker.find(".editor-percentcomplete-buttons").append("

"); + + $input.focus().select(); + + $picker.find(".editor-percentcomplete-slider").slider({ + orientation: "vertical", + range: "min", + value: defaultValue, + slide: function (event, ui) { + $input.val(ui.value) + } + }); + + $picker.find(".editor-percentcomplete-buttons button").bind("click", function (e) { + $input.val($(this).attr("val")); + $picker.find(".editor-percentcomplete-slider").slider("value", $(this).attr("val")); + }) + }; + + this.destroy = function () { + $input.remove(); + $picker.remove(); + }; + + this.focus = function () { + $input.focus(); + }; + + this.loadValue = function (item) { + $input.val(defaultValue = item[args.column.field]); + $input.select(); + }; + + this.serializeValue = function () { + return parseInt($input.val(), 10) || 0; + }; + + this.applyValue = function (item, state) { + item[args.column.field] = state; + }; + + this.isValueChanged = function () { + return (!($input.val() == "" && defaultValue == null)) && ((parseInt($input.val(), 10) || 0) != defaultValue); + }; + + this.validate = function () { + if (isNaN(parseInt($input.val(), 10))) { + return { + valid: false, + msg: "Please enter a valid positive number" + }; + } + + return { + valid: true, + msg: null + }; + }; + + this.init(); + } + + /* + * An example of a "detached" editor. + * The UI is added onto document BODY and .position(), .show() and .hide() are implemented. + * KeyDown events are also handled to provide handling for Tab, Shift-Tab, Esc and Ctrl-Enter. + */ + function LongTextEditor(args) { + var $input, $wrapper; + var defaultValue; + var scope = this; + + this.init = function () { + var $container = $("body"); + + $wrapper = $("
") + .appendTo($container); + + $input = $(" +
From a14bd9bfaa132792255bf7eca310bd0389116ef4 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Thu, 13 Jun 2013 17:07:02 -0400 Subject: [PATCH 49/92] add batch enroll/unenroll display response --- lms/djangoapps/instructor/enrollment.py | 6 +- .../coffee/src/instructor_dashboard.coffee | 72 +++++++++++++++++-- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 10e9cd3c36d4..ad195182c1ef 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -23,7 +23,7 @@ def enroll_emails(course_id, student_emails, auto_enroll=False): return a mapping from status to emails. """ - auto_string = {False: 'allowed', True: 'autoenrolled'}[auto_enroll] + auto_string = {False: 'allowed', True: 'willautoenroll'}[auto_enroll] status_map = { 'user/ce/alreadyenrolled': [], @@ -89,7 +89,7 @@ def unenroll_emails(course_id, student_emails): status_map = { 'cea/disallowed': [], 'ce/unenrolled': [], - 'ce/failed': [], + 'ce/rejected': [], '!ce/notenrolled': [], } @@ -109,7 +109,7 @@ def unenroll_emails(course_id, student_emails): ce.delete() status_map['ce/unenrolled'].append(student_email) except Exception: - status_map['ce/failed'].append(student_email) + status_map['ce/rejected'].append(student_email) except CourseEnrollment.DoesNotExist: status_map['!ce/notenrolled'].append(student_email) diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index 30c41ba315ec..274bfaa763ee 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -77,14 +77,74 @@ setup_section_enrollment = (section) -> 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' - log data - task_response.text JSON.stringify(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' - log data - task_response.text JSON.stringify(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] + label = $ '
', text: msg_txt + 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 + label.append '
' + email + will_attach = true + + if will_attach + task_response.append label + else + label.remove() # setup the data download section From debadae4d34ce2ec1915c88055f582fc24b667dc Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Fri, 14 Jun 2013 09:49:35 -0400 Subject: [PATCH 50/92] style enrollment section --- .../coffee/src/instructor_dashboard.coffee | 12 ++++++++---- .../sass/course/instructor/_instructor_2.scss | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index 274bfaa763ee..b3e3278f9d7f 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -128,23 +128,27 @@ setup_section_enrollment = (section) -> for msg_symbol in message_ordering # task_response.text JSON.stringify(data) msg_txt = msg_to_txt[msg_symbol] - label = $ '
', text: msg_txt + 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 - label.append '
    ' + email + email_list.append $ '
  • ', text: email will_attach = true if will_attach - task_response.append label + task_response.append task_res_section else - label.remove() + task_res_section.remove() # setup the data download section diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 4a68d7749365..e46f3a02b8a3 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -85,6 +85,22 @@ height: 100px; width: 500px; } + + .task-res-section { + h3 { + color: #646464; + } + + ul { + padding: 0; + margin: 0; + margin-top: 0.5em; + line-height: 1.5em; + list-style-type: none; + li { + } + } + } } From c77e9fb721b39d0c51a3fd5523230ecb154fc6ea Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Fri, 14 Jun 2013 14:38:42 -0400 Subject: [PATCH 51/92] add instructor.access with tests --- lms/djangoapps/instructor/access.py | 49 ++++++++ .../instructor/tests/test_access.py | 108 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 lms/djangoapps/instructor/access.py create mode 100644 lms/djangoapps/instructor/tests/test_access.py diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py new file mode 100644 index 000000000000..ce3419ece2ec --- /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/tests/test_access.py b/lms/djangoapps/instructor/tests/test_access.py new file mode 100644 index 000000000000..d97df5d6efd6 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_access.py @@ -0,0 +1,108 @@ +from django.test import TestCase +from django.contrib.auth.models import User, Group +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from django.test.utils import override_settings +from django.conf import settings +from courseware.tests.tests import mongo_store_config, xml_store_config + +from student.models import CourseEnrollment, CourseEnrollmentAllowed +from courseware.access import get_access_group_name +from instructor.access import allow_access, revoke_access + +# mock dependency +# get_access_group_name = lambda course, role: '{0}_{1}'.format(course.course_id, role) + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +# TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestInstructorAccessControlDB(ModuleStoreTestCase): + '''Test instructor access administration against database effects''' + + def setUp(self): + # self.course_id = 'jus:/a/fake/c::rse/id' + # self.course = MockCourse('jus:/a/fake/c::rse/id') + self.course = CourseFactory.create() + + def test_allow(self): + user = UserFactory() + level = 'staff' + + allow_access(self.course, user, level) + + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_allow_twice(self): + user = UserFactory() + level = 'staff' + + allow_access(self.course, user, level) + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + allow_access(self.course, user, level) + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_allow_revoke(self): + user = UserFactory() + level = 'staff' + + allow_access(self.course, user, level) + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + revoke_access(self.course, user, level) + self.assertNotIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + allow_access(self.course, user, level) + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + revoke_access(self.course, user, level) + self.assertNotIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_revoke_without_group(self): + user = UserFactory() + level = 'staff' + + revoke_access(self.course, user, level) + self.assertNotIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_revoke_with_group(self): + user = UserFactory() + level = 'staff' + + Group(name=get_access_group_name(self.course, level)) + revoke_access(self.course, user, level) + self.assertNotIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_allow_disallow_multiuser(self): + users = [UserFactory() for _ in xrange(3)] + levels = ['staff', 'instructor', 'staff'] + antilevels = ['instructor', 'staff', 'instructor'] + + allow_access(self.course, users[0], levels[0]) + allow_access(self.course, users[1], levels[1]) + allow_access(self.course, users[2], levels[2]) + self.assertIn(users[0], Group.objects.get(name=get_access_group_name(self.course, levels[0])).user_set.all()) + self.assertIn(users[1], Group.objects.get(name=get_access_group_name(self.course, levels[1])).user_set.all()) + self.assertIn(users[2], Group.objects.get(name=get_access_group_name(self.course, levels[2])).user_set.all()) + + revoke_access(self.course, users[0], levels[0]) + revoke_access(self.course, users[0], antilevels[0]) + self.assertNotIn(users[0], Group.objects.get(name=get_access_group_name(self.course, levels[0])).user_set.all()) + self.assertIn(users[1], Group.objects.get(name=get_access_group_name(self.course, levels[1])).user_set.all()) + self.assertIn(users[2], Group.objects.get(name=get_access_group_name(self.course, levels[2])).user_set.all()) + + revoke_access(self.course, users[1], levels[1]) + allow_access(self.course, users[0], antilevels[0]) + self.assertNotIn(users[0], Group.objects.get(name=get_access_group_name(self.course, levels[0])).user_set.all()) + self.assertIn(users[0], Group.objects.get(name=get_access_group_name(self.course, antilevels[0])).user_set.all()) + self.assertNotIn(users[1], Group.objects.get(name=get_access_group_name(self.course, levels[1])).user_set.all()) + self.assertIn(users[2], Group.objects.get(name=get_access_group_name(self.course, levels[2])).user_set.all()) + + + # def test_allow_disallow_multirole(self): + + +class MockCourse(object): + def __init__(self, course_id): + self.course_id = course_id From 438617b7821cc73da43dda6597091ee82f3c6ae1 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Fri, 14 Jun 2013 15:08:00 -0400 Subject: [PATCH 52/92] add access_allow_revoke api endpoint (untested) --- lms/djangoapps/instructor/views/api.py | 36 ++++++++++++++++++++++++++ lms/urls.py | 2 ++ 2 files changed, 38 insertions(+) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index c28a15722813..0da4c715fd8b 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -4,6 +4,7 @@ Non-html views which the instructor dashboard requests. TODO add tracking +TODO a lot of these GETs should be PUTs """ import json @@ -12,8 +13,10 @@ from django.http import HttpResponse from courseware.courses import get_course_with_access +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 @@ -41,6 +44,39 @@ def enroll_unenroll(request, course_id): 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): diff --git a/lms/urls.py b/lms/urls.py index bbe559a65eaa..97a2e594dba4 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -272,6 +272,8 @@ # api endpoints for instructor url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enroll_unenroll$', 'instructor.views.api.enroll_unenroll', name="enroll_unenroll"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/access_allow_revoke$', + 'instructor.views.api.access_allow_revoke', name="access_allow_revoke"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/grading_config$', 'instructor.views.api.grading_config', name="grading_config"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enrolled_students_profiles(?P/csv)?$', From 12ab6199c44469bb255a2a9e6e8469677e36f705 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Fri, 14 Jun 2013 15:38:53 -0400 Subject: [PATCH 53/92] refactor template handling for easy reorder/disable/permissions --- .../instructor/views/instructor_dashboard.py | 57 ++++++++++---- .../coffee/src/instructor_dashboard.coffee | 5 +- .../sass/course/instructor/_instructor_2.scss | 6 +- .../instructor_dashboard_2/analytics.html | 11 +++ .../instructor_dashboard_2/course_info.html | 25 +++--- .../instructor_dashboard_2/data_download.html | 11 +++ .../instructor_dashboard_2/enrollment.html | 10 +++ .../instructor_dashboard_2.html | 78 ++----------------- .../instructor_dashboard_2/student_admin.html | 23 ++++++ 9 files changed, 122 insertions(+), 104 deletions(-) create mode 100644 lms/templates/courseware/instructor_dashboard_2/analytics.html create mode 100644 lms/templates/courseware/instructor_dashboard_2/data_download.html create mode 100644 lms/templates/courseware/instructor_dashboard_2/enrollment.html create mode 100644 lms/templates/courseware/instructor_dashboard_2/student_admin.html diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index e5ea6d61076f..ea60f465afb8 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -15,10 +15,11 @@ 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 @@ 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() @@ -82,6 +102,8 @@ def _section_course_info(request, course_id): def _section_enrollment(course_id): """ Provide data for the corresponding dashboard section """ 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}), } @@ -90,13 +112,18 @@ def _section_enrollment(course_id): def _section_student_admin(course_id): """ Provide data for the corresponding dashboard section """ - section_data = {} + 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}), } @@ -106,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 b3e3278f9d7f..8f41ed1b229a 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,14 +51,14 @@ 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_enrollment idash_content.find(".#{CSS_IDASH_SECTION}#enrollment") - setup_section_data_download idash_content.find(".#{CSS_IDASH_SECTION}#data-download") + setup_section_data_download idash_content.find(".#{CSS_IDASH_SECTION}#data_download") setup_section_analytics idash_content.find(".#{CSS_IDASH_SECTION}#analytics") diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index e46f3a02b8a3..fd26641561db 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -55,7 +55,7 @@ } -.instructor-dashboard-wrapper-2 section.idash-section#course-info { +.instructor-dashboard-wrapper-2 section.idash-section#course_info { .error-log { margin-top: 1em; @@ -104,7 +104,7 @@ } -.instructor-dashboard-wrapper-2 section.idash-section#student-admin { +.instructor-dashboard-wrapper-2 section.idash-section#student_admin { .h-row { margin-bottom: 1em; clear: both; @@ -116,7 +116,7 @@ } -.instructor-dashboard-wrapper-2 section.idash-section#data-download { +.instructor-dashboard-wrapper-2 section.idash-section#data_download { input { // display: block; margin-bottom: 1em; diff --git a/lms/templates/courseware/instructor_dashboard_2/analytics.html b/lms/templates/courseware/instructor_dashboard_2/analytics.html new file mode 100644 index 000000000000..d8defba74cee --- /dev/null +++ b/lms/templates/courseware/instructor_dashboard_2/analytics.html @@ -0,0 +1,11 @@ +<%page args="section_data"/> + +

    Distributions

    + +
    +
    +
    +
    +
    diff --git a/lms/templates/courseware/instructor_dashboard_2/course_info.html b/lms/templates/courseware/instructor_dashboard_2/course_info.html index 52c3e7e86f1d..9dcfa9a39056 100644 --- a/lms/templates/courseware/instructor_dashboard_2/course_info.html +++ b/lms/templates/courseware/instructor_dashboard_2/course_info.html @@ -1,44 +1,46 @@ +<%page args="section_data"/> +

    Course Information

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

    Course Errors:

    - %for error in section_data['course_info']['course_errors']: + %for error in section_data['course_errors']:
    ${ error[0] }
    ${ error[1] } @@ -46,8 +48,3 @@

    Course Errors:

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

    Batch Enrollment

    +

    Enter student emails separated by new lines or commas.

    + + + +
    +
    diff --git a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html index a12316f0b3b1..511ae868148f 100644 --- a/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/courseware/instructor_dashboard_2/instructor_dashboard_2.html @@ -38,81 +38,19 @@

    Instructor Dashboard

    ## the links are acativated and handled in instructor_dashboard.coffee ## when the javascript loads, it clicks on idash-default-section

    - Course Info - Enrollment - Student Admin - Data Download - Analytics + % for section_data in sections: + ${ section_data['section_display_name'] } + % endfor

    ## each section corresponds to a section_data sub-dictionary provided by the view ## to keep this short, sections can be pulled out into their own files -
    - <%include file="course_info.html"/> -
    - - -
    -
    -

    Batch Enrollment

    -

    Enter student emails separated by new lines or commas.

    - - - -
    -
    -
    - - -
    -
    -

    Select student

    - -
    - -
    -

    grade

    -

    85 (B)

    -
    - - - -
    - - -
    -
    - - -
    - - - - - -
    -
    -
    -
    -
    - -
    -

    Distributions

    - -
    -
    -
    -
    -
    -
    + % for section_data in sections: +
    + <%include file="${ section_data['section_key'] }.html" args="section_data=section_data" /> +
    + % endfor
    diff --git a/lms/templates/courseware/instructor_dashboard_2/student_admin.html b/lms/templates/courseware/instructor_dashboard_2/student_admin.html new file mode 100644 index 000000000000..32f169cc0264 --- /dev/null +++ b/lms/templates/courseware/instructor_dashboard_2/student_admin.html @@ -0,0 +1,23 @@ +<%page args="section_data"/> + +
    +

    Select student

    + +
    + ## +
    +

    grade

    +

    85 (B)

    +
    + ## + + ## +
    + + +
    From b3d1c69d092f514f2f885cb135b94d1dcfafefd8 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Fri, 14 Jun 2013 15:58:58 -0400 Subject: [PATCH 54/92] rename enrollment section to membership --- lms/djangoapps/instructor/views/instructor_dashboard.py | 8 ++++---- lms/static/coffee/src/instructor_dashboard.coffee | 6 +++--- lms/static/sass/course/instructor/_instructor_2.scss | 2 +- .../{enrollment.html => membership.html} | 0 4 files changed, 8 insertions(+), 8 deletions(-) rename lms/templates/courseware/instructor_dashboard_2/{enrollment.html => membership.html} (100%) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index ea60f465afb8..faa5ebcef385 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -42,7 +42,7 @@ def instructor_dashboard_2(request, course_id): sections = [ _section_course_info(course_id), - _section_enrollment(course_id), + _section_membership(course_id), _section_student_admin(course_id), _section_data_download(course_id), _section_analytics(course_id), @@ -99,11 +99,11 @@ def _section_course_info(course_id): return section_data -def _section_enrollment(course_id): +def _section_membership(course_id): """ Provide data for the corresponding dashboard section """ section_data = { - 'section_key': 'enrollment', - 'section_display_name': 'Enrollment', + 'section_key': 'membership', + '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}), } diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index 8f41ed1b229a..00cf09bc4c61 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -57,14 +57,14 @@ setup_instructor_dashboard = (idash_content) => # call setup handlers for each section setup_instructor_dashboard_sections = (idash_content) -> log "setting up instructor dashboard sections" - setup_section_enrollment idash_content.find(".#{CSS_IDASH_SECTION}#enrollment") 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_enrollment = (section) -> - log "setting up instructor dashboard section - enrollment" +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']'") diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index fd26641561db..6f5688e166f4 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -76,7 +76,7 @@ } -.instructor-dashboard-wrapper-2 section.idash-section#enrollment { +.instructor-dashboard-wrapper-2 section.idash-section#membership { div { margin-top: 2em; } diff --git a/lms/templates/courseware/instructor_dashboard_2/enrollment.html b/lms/templates/courseware/instructor_dashboard_2/membership.html similarity index 100% rename from lms/templates/courseware/instructor_dashboard_2/enrollment.html rename to lms/templates/courseware/instructor_dashboard_2/membership.html From dcdfdd39c412d1f8bde85f35a25d28878c12fb53 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 17 Jun 2013 13:26:04 -0400 Subject: [PATCH 55/92] add list_with_level endpoint --- lms/djangoapps/instructor/access.py | 8 ++++++ lms/djangoapps/instructor/views/api.py | 28 ++++++++++++++++++- .../instructor/views/instructor_dashboard.py | 1 + lms/urls.py | 2 ++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index ce3419ece2ec..055598c3d998 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -13,6 +13,14 @@ 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/views/api.py b/lms/djangoapps/instructor/views/api.py index 0da4c715fd8b..bcfd5f0e47e1 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -16,7 +16,7 @@ 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 +from instructor.access import allow_access, revoke_access, list_with_level import analytics.basic import analytics.distributions import analytics.csvs @@ -77,6 +77,32 @@ def access_allow_revoke(request, course_id): 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, + 'instructors': 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 + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) def grading_config(request, course_id): diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index faa5ebcef385..f0aed0c4a71b 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -106,6 +106,7 @@ 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}), } return section_data diff --git a/lms/urls.py b/lms/urls.py index 97a2e594dba4..050e7046b992 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -272,6 +272,8 @@ # 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$', From 44e60162608f365e836c8315d3301cae44e8c324 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 17 Jun 2013 13:26:15 -0400 Subject: [PATCH 56/92] add fake 6002x staff script --- .../commands/assign_6002x_fake_staff.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 lms/djangoapps/instructor/management/commands/assign_6002x_fake_staff.py diff --git a/lms/djangoapps/instructor/management/commands/assign_6002x_fake_staff.py b/lms/djangoapps/instructor/management/commands/assign_6002x_fake_staff.py new file mode 100644 index 000000000000..6000c69fa125 --- /dev/null +++ b/lms/djangoapps/instructor/management/commands/assign_6002x_fake_staff.py @@ -0,0 +1,51 @@ +# creates users named johndoen with emails of jdn@edx.org +# they are enrolled in 600x and have fake grades with + +from optparse import make_option +import json +import random +from datetime import datetime + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from django.contrib.auth.models import User +from student.models import UserProfile, CourseEnrollment +from courseware.models import StudentModule +from courseware.courses import get_course_by_id + +from instructor.access import allow_access, revoke_access + + +class Command(BaseCommand): + + args = '<>' + help = """ + Add fake students and grades to db. + """ + + def _delete_all_jds(self): + [student.delete() for student in User.objects.filter(username__contains="johndoe")] + + def handle(self, *args, **options): + course_id = 'MITx/6.002x/2013_Spring' + course = get_course_by_id(course_id) + self.set_level(course, User.objects.get(email='jd101@edx.org'), 'instructor') + self.set_level(course, User.objects.get(email='jd102@edx.org'), 'instructor') + self.set_level(course, User.objects.get(email='jd103@edx.org'), 'instructor') + self.set_level(course, User.objects.get(email='jd104@edx.org'), 'staff') + self.set_level(course, User.objects.get(email='jd105@edx.org'), 'staff') + self.set_level(course, User.objects.get(email='jd106@edx.org'), 'staff') + self.set_level(course, User.objects.get(email='jd107@edx.org'), 'staff') + self.set_level(course, User.objects.get(email='jd108@edx.org'), 'staff') + self.set_level(course, User.objects.get(email='jd109@edx.org'), 'staff') + + def set_level(self, course, user, level): + """ level is one of [None, 'staff', 'instructor'] """ + revoke_access(course, user, 'instructor') + revoke_access(course, user, 'staff') + + if level == 'staff': + allow_access(course, user, level) + if level == 'instructor': + allow_access(course, user, level) From d066d6e2ff0d2c8795dc27d86b28aca6fa1ca046 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 17 Jun 2013 13:37:07 -0400 Subject: [PATCH 57/92] add access comments --- lms/djangoapps/instructor/views/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index bcfd5f0e47e1..9f1511a8e03e 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -27,6 +27,7 @@ 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 +49,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 From 74e71fa39b3328dc936462a1c43821bc99af50bc Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 17 Jun 2013 13:38:07 -0400 Subject: [PATCH 58/92] add staff management subsections --- .../instructor/views/instructor_dashboard.py | 1 + .../coffee/src/instructor_dashboard.coffee | 235 ++++++++++++------ .../sass/course/instructor/_instructor_2.scss | 27 +- .../instructor_dashboard_2/membership.html | 21 +- 4 files changed, 198 insertions(+), 86 deletions(-) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index f0aed0c4a71b..ac78d765ad3d 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -107,6 +107,7 @@ def _section_membership(course_id): '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 diff --git a/lms/static/coffee/src/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard.coffee index 00cf09bc4c61..61be2ed947dd 100644 --- a/lms/static/coffee/src/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard.coffee @@ -66,88 +66,159 @@ setup_instructor_dashboard_sections = (idash_content) -> 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_batch_enrollment = -> + log "setting up instructor dashboard subsection - batch enrollment" + + subsection = section.find('.batch-enrollment') + emails_input = subsection.find("textarea[name='student-emails']'") + btn_enroll = subsection.find("input[name='enroll']'") + btn_unenroll = subsection.find("input[name='unenroll']'") + task_response = subsection.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_instructor_staff_management = -> + log 'setting up instructor dashboard subsection - instructor staff management' + + subsection = section.find('.instructor_staff_management') + display_table = subsection.find('.staff-management-table') + add_section = subsection.find('.add-staff') + allow_field = add_section.find("input[name='staff-email']") + allow_button = add_section.find("input[name='staff-allow']") + list_endpoint = display_table.data 'endpoint' + access_change_endpoint = add_section.data 'endpoint' + + reload_staff_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.staff + 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, 'staff', 'revoke', reload_staff_list) + + allow_button.click -> + access_change(allow_field.val(), 'staff', 'allow', reload_staff_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?() + + reload_staff_list() + + + setup_batch_enrollment() + setup_instructor_staff_management() # setup the data download section diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 6f5688e166f4..ffe45f732103 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -77,8 +77,29 @@ .instructor-dashboard-wrapper-2 section.idash-section#membership { - div { - margin-top: 2em; + .vert-left { + float: left; + width: 45%; + } + + .vert-right { + float: right; + width: 45%; + + .management-section { + margin-bottom: 1.5em; + + .staff-management-table { + .slickgrid { + height: 250px; + } + } + + .add-staff { + margin-top: 0.5em; + } + } + } textarea { @@ -87,6 +108,8 @@ } .task-res-section { + margin-top: 1.5em; + h3 { color: #646464; } diff --git a/lms/templates/courseware/instructor_dashboard_2/membership.html b/lms/templates/courseware/instructor_dashboard_2/membership.html index d31e2ea10b0d..38f8bf04a695 100644 --- a/lms/templates/courseware/instructor_dashboard_2/membership.html +++ b/lms/templates/courseware/instructor_dashboard_2/membership.html @@ -1,10 +1,27 @@ <%page args="section_data"/> -
        +

        Batch Enrollment

        Enter student emails separated by new lines or commas.

        - + +
        + +
        +
        +

        Staff Management

        +
        +
        + + +
        +
        + +
        +

        Instructor Management

        + ##
        +
        +
        From 85e3f496defd22e14c5af708c0d6d131a4e214fb Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 17 Jun 2013 14:41:09 -0400 Subject: [PATCH 59/92] refactor analytics section to its own file --- .../src/instructor_dashboard/analytics.coffee | 95 +++++++++++++++++++ .../instructor_dashboard.coffee | 94 +----------------- 2 files changed, 100 insertions(+), 89 deletions(-) create mode 100644 lms/static/coffee/src/instructor_dashboard/analytics.coffee rename lms/static/coffee/src/{ => instructor_dashboard}/instructor_dashboard.coffee (72%) 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 000000000000..4835c2fe303b --- /dev/null +++ b/lms/static/coffee/src/instructor_dashboard/analytics.coffee @@ -0,0 +1,95 @@ +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') + + 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 = $ '