diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index cd0b230423cba2ef341402804fdf0f8bcc7298f0..c5b1b21b52e769d281cf9aaf9168d2d161d2797e 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -446,6 +446,19 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa self.assertEqual(student_json['username'], student.username) self.assertEqual(student_json['email'], student.email) + def test_get_anon_ids(self): + """ + Test the CSV output for the anonymized user ids. + """ + url = reverse('get_anon_ids', kwargs={'course_id': self.course.id}) + with patch('instructor.views.api.unique_id_for_user') as mock_unique: + mock_unique.return_value = '42' + response = self.client.get(url, {}) + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertTrue(body.startswith('"User ID","Anonymized user ID"\n"2","42"\n')) + self.assertTrue(body.endswith('"7","42"\n')) + def test_get_students_features_csv(self): """ Test that some minimum of information is formatted diff --git a/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py b/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py new file mode 100644 index 0000000000000000000000000000000000000000..947b72f2985435f5ced4d17815058c46be3f42e6 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py @@ -0,0 +1,71 @@ +""" +Unit tests for instructor dashboard + +Based on (and depends on) unit tests for courseware. + +Notes for running by hand: + +./manage.py lms --settings test test lms/djangoapps/instructor +""" + +from django.test.utils import override_settings + +# Need access to internal func to put users in the right group +from django.contrib.auth.models import Group, User + +from django.core.urlresolvers import reverse + +from courseware.access import _course_staff_group_name +from courseware.tests.helpers import LoginEnrollmentTestCase +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.django import modulestore, clear_existing_modulestores +import xmodule.modulestore.django + +from mock import patch + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestInstructorDashboardAnonCSV(ModuleStoreTestCase, LoginEnrollmentTestCase): + ''' + Check for download of csv + ''' + + # Note -- I copied this setUp from a similar test + def setUp(self): + clear_existing_modulestores() + self.toy = modulestore().get_course("edX/toy/2012_Fall") + + # Create two accounts + self.student = 'view@test.com' + self.instructor = 'view2@test.com' + self.password = 'foo' + self.create_account('u1', self.student, self.password) + self.create_account('u2', self.instructor, self.password) + self.activate_user(self.student) + self.activate_user(self.instructor) + + def make_instructor(course): + """ Create an instructor for the course. """ + group_name = _course_staff_group_name(course.location) + group = Group.objects.create(name=group_name) + group.user_set.add(User.objects.get(email=self.instructor)) + + make_instructor(self.toy) + + self.logout() + self.login(self.instructor, self.password) + self.enroll(self.toy) + + def test_download_anon_csv(self): + course = self.toy + url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) + + with patch('instructor.views.legacy.unique_id_for_user') as mock_unique: + mock_unique.return_value = 42 + response = self.client.post(url, {'action': 'Download CSV of all student anonymized IDs'}) + + self.assertEqual(response['Content-Type'], 'text/csv') + body = response.content.replace('\r', '') + self.assertEqual(body, '"User ID","Anonymized user ID"\n"2","42"\n') + diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 6c68b7fed65d529e17cc570c027a60437aa7df8c..8a552feb66f062a75adfd91d908feba746ce65cd 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -28,6 +28,7 @@ from django_comment_common.models import (Role, FORUM_ROLE_COMMUNITY_TA) from courseware.models import StudentModule +from student.models import unique_id_for_user import instructor_task.api from instructor_task.api_helper import AlreadyRunningError import instructor.enrollment as enrollment @@ -37,6 +38,7 @@ import instructor.access as access import analytics.basic import analytics.distributions import analytics.csvs +import csv log = logging.getLogger(__name__) @@ -368,6 +370,38 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=W06 return analytics.csvs.create_csv_response("enrolled_profiles.csv", header, datarows) +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def get_anon_ids(request, course_id): # pylint: disable=W0613 + """ + Respond with 2-column CSV output of user-id, anonymized-user-id + """ + # TODO: the User.objects query and CSV generation here could be + # centralized into analytics. Currently analytics has similar functionality + # but not quite what's needed. + def csv_response(filename, header, rows): + """Returns a CSV http response for the given header and rows (excel/utf-8).""" + response = HttpResponse(mimetype='text/csv') + response['Content-Disposition'] = 'attachment; filename={0}'.format(filename) + writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) + # In practice, there should not be non-ascii data in this query, + # but trying to do the right thing anyway. + encoded = [unicode(s).encode('utf-8') for s in header] + writer.writerow(encoded) + for row in rows: + encoded = [unicode(s).encode('utf-8') for s in row] + writer.writerow(encoded) + return response + + students = User.objects.filter( + courseenrollment__course_id=course_id, + ).order_by('id') + header =['User ID', 'Anonymized user ID'] + rows = [[s.id, unique_id_for_user(s)] for s in students] + return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows) + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 8c67c24a77487c56db1e4dbf00bfb2ca1b90e228..07af69558f40499b2856885dec0f84824b50c9ca 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -16,6 +16,8 @@ urlpatterns = patterns('', # nopep8 'instructor.views.api.get_grading_config', name="get_grading_config"), url(r'^get_students_features(?P<csv>/csv)?$', 'instructor.views.api.get_students_features', name="get_students_features"), + url(r'^get_anon_ids$', + 'instructor.views.api.get_anon_ids', name="get_anon_ids"), url(r'^get_distribution$', 'instructor.views.api.get_distribution', name="get_distribution"), url(r'^get_student_progress_url$', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 9a1bea222ea60892622c4a04d9319b2d7fcc1f2f..031eac266bfeca6cb33abc632c04a9a5aadb2731 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -133,6 +133,7 @@ def _section_data_download(course_id): 'section_display_name': _('Data Download'), 'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': course_id}), 'get_students_features_url': reverse('get_students_features', kwargs={'course_id': course_id}), + 'get_anon_ids_url': reverse('get_anon_ids', kwargs={'course_id': course_id}), } return section_data diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 2efc7e1344211643d3c7909fe965da4a0f27dd8c..c7e32a52ae456d4483f0715e74bbf47dbf6c0958 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -50,7 +50,7 @@ from instructor_task.api import (get_running_instructor_tasks, from instructor_task.views import get_task_completion_info from mitxmako.shortcuts import render_to_response from psychometrics import psychoanalyze -from student.models import CourseEnrollment, CourseEnrollmentAllowed +from student.models import CourseEnrollment, CourseEnrollmentAllowed, unique_id_for_user from student.views import course_from_id import track.views from mitxmako.shortcuts import render_to_string @@ -584,6 +584,15 @@ def instructor_dashboard(request, course_id): datatable['title'] = 'Student state for problem %s' % problem_to_dump return return_csv('student_state_from_%s.csv' % problem_to_dump, datatable) + elif 'Download CSV of all student anonymized IDs' in action: + students = User.objects.filter( + courseenrollment__course_id=course_id, + ).order_by('id') + + datatable = {'header': ['User ID', 'Anonymized user ID']} + datatable['data'] = [[s.id, unique_id_for_user(s)] for s in students] + return return_csv(course_id.replace('/', '-') + '-anon-ids.csv', datatable) + #---------------------------------------- # Group management diff --git a/lms/static/coffee/src/instructor_dashboard/data_download.coffee b/lms/static/coffee/src/instructor_dashboard/data_download.coffee index cfd3534e04241ebb8eb1b661329d086016857962..ee9be4254dc959d8fa3c25b53713a28bdb7ffd47 100644 --- a/lms/static/coffee/src/instructor_dashboard/data_download.coffee +++ b/lms/static/coffee/src/instructor_dashboard/data_download.coffee @@ -16,10 +16,16 @@ class DataDownload @$display_table = @$display.find '.data-display-table' @$request_response_error = @$display.find '.request-response-error' @$list_studs_btn = @$section.find("input[name='list-profiles']'") + @$list_anon_btn = @$section.find("input[name='list-anon-ids']'") @$grade_config_btn = @$section.find("input[name='dump-gradeconf']'") # attach click handlers + # The list-anon case is always CSV + @$list_anon_btn.click (e) => + url = @$list_anon_btn.data 'endpoint' + location.href = url + # this handler binds to both the download # and the csv button @$list_studs_btn.click (e) => diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index effa5852d8b832352f9de62369b506fcd9d25e9e..7f41c82c9dea3c63ca710cafaed8dd3da7daf377 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -416,6 +416,9 @@ function goto( mode) <input type="text" name="problem_to_dump" size="40"> <input type="submit" name="action" value="Download CSV of all responses to problem"> </p> + <p> + <input type="submit" name="action" value="Download CSV of all student anonymized IDs"> + </p> <hr width="40%" style="align:left"> %endif diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index 196d9d580b72add1e6bd568c8986d2501b5f3374..c9f15bdca4e66428e6097b0ab626a1ff54497f05 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -10,6 +10,8 @@ ## <input type="button" name="list-answer-distributions" value="Answer distributions (x students got y points)"> ## <br> <input type="button" name="dump-gradeconf" value="${_("Grading Configuration")}" data-endpoint="${ section_data['get_grading_config_url'] }"> +<input type="button" name="list-anon-ids" value="${_("Get Student Anonymized IDs CSV")}" data-csv="true" class="csv" data-endpoint="${ section_data['get_anon_ids_url'] }"> + <div class="data-display"> <div class="data-display-text"></div> diff --git a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html index aeb860854365a21e8d3f4ff1a5c9d5eda7c83bd7..c209db0103b8a13549fb68a8e423580cb6ead5c8 100644 --- a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html @@ -3,6 +3,19 @@ <%! from django.core.urlresolvers import reverse %> <%namespace name='static' file='/static_content.html'/> +## ----- Tips on adding something to the new instructor dashboard ----- +## 1. add your input element, e.g. in instructor_dashboard2/data_download.html +## the input includes a reference like data-endpoint="${ section_data['get_anon_ids_url'] }" +## 2. Go to the old dashboard djangoapps/instructor/views/instructor_dashboard.py and +## add in a definition of 'xxx_url' in the right section_data for whatever page your +## feature is on. +## 3. Add a url() entry in api_urls.py +## 4. Over in lms/static/coffee/src/instructor_dashboard/ there there are .coffee files +## for each page which define the .js. Edit this to make your input do something +## when clicked. The .coffee files use the name=xx to pick out inputs, not id= +## 5. Implement your standard django/python in lms/djangoapps/instructor/views/api.py +## 6. And tests go in lms/djangoapps/instructor/tests/ + <%block name="headextra"> <%static:css group='course'/> <script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>