Commit 08cb4694 by Nick Parlante

Merge pull request #1104 from edx/nick/anon-userids

Add "Download CSV of all student anonymized IDs" button to instructor dashboards
parents 78829a37 d225b85d
......@@ -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
......
"""
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')
......@@ -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__)
......@@ -371,6 +373,38 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=W06
@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')
def get_distribution(request, course_id):
"""
Respond with json of the distribution of students over selected features which have choices.
......
......@@ -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$',
......
......@@ -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
......
......@@ -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
......
......@@ -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) =>
......
......@@ -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
......
......@@ -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>
......
......@@ -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>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment