From 1b5903935e1e8c9fe030ad3d3de9f71471447b8f Mon Sep 17 00:00:00 2001
From: Nick Parlante
Date: Mon, 23 Sep 2013 12:40:41 -0700
Subject: [PATCH 1/2] Add "Download CSV of all student anonymized IDs" button
to instructor dashboard
This is a recurrent ops problem, so we wanted to make it available
on the instructor dashboard.
---
lms/djangoapps/instructor/views/legacy.py | 11 ++++++++++-
lms/templates/courseware/instructor_dashboard.html | 3 +++
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py
index 2efc7e1344..df467b8637 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/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html
index effa5852d8..7f41c82c9d 100644
--- a/lms/templates/courseware/instructor_dashboard.html
+++ b/lms/templates/courseware/instructor_dashboard.html
@@ -416,6 +416,9 @@ function goto( mode)
+
+
+
%endif
From d225b85d1a9da216e7801e27f8df246e5f565e0c Mon Sep 17 00:00:00 2001
From: Nick Parlante
Date: Tue, 24 Sep 2013 12:59:19 -0700
Subject: [PATCH 2/2] Port get-anonymized-id feature to new instructor
dashboard
---
lms/djangoapps/instructor/tests/test_api.py | 13 ++++
.../instructor/tests/test_legacy_anon_csv.py | 71 +++++++++++++++++++
lms/djangoapps/instructor/views/api.py | 34 +++++++++
lms/djangoapps/instructor/views/api_urls.py | 2 +
.../instructor/views/instructor_dashboard.py | 1 +
lms/djangoapps/instructor/views/legacy.py | 2 +-
.../instructor_dashboard/data_download.coffee | 6 ++
.../instructor_dashboard_2/data_download.html | 2 +
.../instructor_dashboard_2.html | 13 ++++
9 files changed, 143 insertions(+), 1 deletion(-)
create mode 100644 lms/djangoapps/instructor/tests/test_legacy_anon_csv.py
diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py
index cd0b230423..c5b1b21b52 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 0000000000..947b72f298
--- /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 6c68b7fed6..8a552feb66 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 8c67c24a77..07af69558f 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)?$',
'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 9a1bea222e..031eac266b 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 df467b8637..c7e32a52ae 100644
--- a/lms/djangoapps/instructor/views/legacy.py
+++ b/lms/djangoapps/instructor/views/legacy.py
@@ -591,7 +591,7 @@ def instructor_dashboard(request, course_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)
+ 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 cfd3534e04..ee9be4254d 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/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html
index 196d9d580b..c9f15bdca4 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 @@
##
##
+
+
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 aeb8608543..c209db0103 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'/>