Commit d225b85d by Nick Parlante

Port get-anonymized-id feature to new instructor dashboard

parent 1b590393
...@@ -446,6 +446,19 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa ...@@ -446,6 +446,19 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
self.assertEqual(student_json['username'], student.username) self.assertEqual(student_json['username'], student.username)
self.assertEqual(student_json['email'], student.email) 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): def test_get_students_features_csv(self):
""" """
Test that some minimum of information is formatted 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, ...@@ -28,6 +28,7 @@ from django_comment_common.models import (Role,
FORUM_ROLE_COMMUNITY_TA) FORUM_ROLE_COMMUNITY_TA)
from courseware.models import StudentModule from courseware.models import StudentModule
from student.models import unique_id_for_user
import instructor_task.api import instructor_task.api
from instructor_task.api_helper import AlreadyRunningError from instructor_task.api_helper import AlreadyRunningError
import instructor.enrollment as enrollment import instructor.enrollment as enrollment
...@@ -37,6 +38,7 @@ import instructor.access as access ...@@ -37,6 +38,7 @@ import instructor.access as access
import analytics.basic import analytics.basic
import analytics.distributions import analytics.distributions
import analytics.csvs import analytics.csvs
import csv
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -371,6 +373,38 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=W06 ...@@ -371,6 +373,38 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=W06
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff') @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): def get_distribution(request, course_id):
""" """
Respond with json of the distribution of students over selected features which have choices. Respond with json of the distribution of students over selected features which have choices.
......
...@@ -16,6 +16,8 @@ urlpatterns = patterns('', # nopep8 ...@@ -16,6 +16,8 @@ urlpatterns = patterns('', # nopep8
'instructor.views.api.get_grading_config', name="get_grading_config"), 'instructor.views.api.get_grading_config', name="get_grading_config"),
url(r'^get_students_features(?P<csv>/csv)?$', url(r'^get_students_features(?P<csv>/csv)?$',
'instructor.views.api.get_students_features', name="get_students_features"), '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$', url(r'^get_distribution$',
'instructor.views.api.get_distribution', name="get_distribution"), 'instructor.views.api.get_distribution', name="get_distribution"),
url(r'^get_student_progress_url$', url(r'^get_student_progress_url$',
......
...@@ -133,6 +133,7 @@ def _section_data_download(course_id): ...@@ -133,6 +133,7 @@ def _section_data_download(course_id):
'section_display_name': _('Data Download'), 'section_display_name': _('Data Download'),
'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': course_id}), '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_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 return section_data
......
...@@ -591,7 +591,7 @@ def instructor_dashboard(request, course_id): ...@@ -591,7 +591,7 @@ def instructor_dashboard(request, course_id):
datatable = {'header': ['User ID', 'Anonymized user ID']} datatable = {'header': ['User ID', 'Anonymized user ID']}
datatable['data'] = [[s.id, unique_id_for_user(s)] for s in students] datatable['data'] = [[s.id, unique_id_for_user(s)] for s in students]
return return_csv(course_id.replace('/', '-')+'-anon-ids.csv', datatable) return return_csv(course_id.replace('/', '-') + '-anon-ids.csv', datatable)
#---------------------------------------- #----------------------------------------
# Group management # Group management
......
...@@ -16,10 +16,16 @@ class DataDownload ...@@ -16,10 +16,16 @@ class DataDownload
@$display_table = @$display.find '.data-display-table' @$display_table = @$display.find '.data-display-table'
@$request_response_error = @$display.find '.request-response-error' @$request_response_error = @$display.find '.request-response-error'
@$list_studs_btn = @$section.find("input[name='list-profiles']'") @$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']'") @$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
# attach click handlers # 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 # this handler binds to both the download
# and the csv button # and the csv button
@$list_studs_btn.click (e) => @$list_studs_btn.click (e) =>
......
...@@ -10,6 +10,8 @@ ...@@ -10,6 +10,8 @@
## <input type="button" name="list-answer-distributions" value="Answer distributions (x students got y points)"> ## <input type="button" name="list-answer-distributions" value="Answer distributions (x students got y points)">
## <br> ## <br>
<input type="button" name="dump-gradeconf" value="${_("Grading Configuration")}" data-endpoint="${ section_data['get_grading_config_url'] }"> <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">
<div class="data-display-text"></div> <div class="data-display-text"></div>
......
...@@ -3,6 +3,19 @@ ...@@ -3,6 +3,19 @@
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='/static_content.html'/> <%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"> <%block name="headextra">
<%static:css group='course'/> <%static:css group='course'/>
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script> <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