Commit 7392a888 by Miles Steele

cleanup, fix permissions, rename functions, add more api tests

parent 1e8cb7df
......@@ -36,6 +36,7 @@ def enrolled_students_features(course_id, features):
student_dict = dict((feature, getattr(student, feature))
for feature in student_features)
profile = student.profile
if not profile is None:
profile_dict = dict((feature, getattr(profile, feature))
for feature in profile_features)
student_dict.update(profile_dict)
......
"""
Profile Distributions
Aggregate sums for values of fields in students profiles.
For example:
The distribution in a course for gender might look like:
'gender': {
'type': 'EASY_CHOICE',
'data': {
'no_data': 1234,
'm': 5678,
'o': 2134,
'f': 5678
},
'display_names': {
'no_data': 'No Data',
'm': 'Male',
'o': 'Other',
'f': 'Female'
}
"""
from django.db.models import Count
......@@ -8,6 +27,48 @@ from student.models import CourseEnrollment, UserProfile
_EASY_CHOICE_FEATURES = ('gender', 'level_of_education')
_OPEN_CHOICE_FEATURES = ('year_of_birth',)
AVAILABLE_PROFILE_FEATURES = _EASY_CHOICE_FEATURES + _OPEN_CHOICE_FEATURES
DISPLAY_NAMES = {
'gender': 'Gender',
'level_of_education': 'Level of Education',
'year_of_birth': 'Year Of Birth',
}
class ProfileDistribution(object):
"""
Container for profile distribution data
`feature` is the name of the distribution feature
`feature_display_name` is the display name of feature
`data` is a dictionary of the distribution
`type` is either 'EASY_CHOICE' or 'OPEN_CHOICE'
`choices_display_names` is a dict if the distribution is an 'EASY_CHOICE'
"""
class ValidationError(ValueError):
""" Error thrown if validation fails. """
pass
def __init__(self, feature):
self.feature = feature
self.feature_display_name = DISPLAY_NAMES[feature]
def validate(self):
"""
Validate this profile distribution.
Throws ProfileDistribution.ValidationError
"""
def validation_assert(predicate):
if not predicate:
raise ProfileDistribution.ValidationError()
validation_assert(isinstance(self.feature, str))
validation_assert(isinstance(self.feature_display_name, str))
validation_assert(self.type in ['EASY_CHOICE', 'OPEN_CHOICE'])
validation_assert(isinstance(self.data, dict))
if self.type == 'EASY_CHOICE':
validation_assert(isinstance(self.choices_display_names, dict))
def profile_distribution(course_id, feature):
......@@ -15,37 +76,35 @@ def profile_distribution(course_id, feature):
Retrieve distribution of students over a given feature.
feature is one of AVAILABLE_PROFILE_FEATURES.
Return a dictionary {
'type': 'SOME_TYPE',
'data': {'key': 'val'},
'display_names': {'key': 'displaynameval'}
}
Returns a ProfileDistribution instance.
display_names is only return for EASY_CHOICE type eatuers
note no_data instead of None to be compatible with the json spec.
data types e.g.
NOTE: no_data will appear as a key instead of None to be compatible with the json spec.
data types are
EASY_CHOICE - choices with a restricted domain, e.g. level_of_education
OPEN_CHOICE - choices with a larger domain e.g. year_of_birth
"""
feature_results = {}
if not feature in AVAILABLE_PROFILE_FEATURES:
raise ValueError(
"unsupported feature requested for distribution '{}'".format(
feature)
)
prd = ProfileDistribution(feature)
if feature in _EASY_CHOICE_FEATURES:
prd.type = 'EASY_CHOICE'
if feature == 'gender':
raw_choices = UserProfile.GENDER_CHOICES
elif feature == 'level_of_education':
raw_choices = UserProfile.LEVEL_OF_EDUCATION_CHOICES
# short name and display nae (full) of the choices.
choices = [(short, full)
for (short, full) in raw_choices] + [('no_data', 'No Data')]
data = {}
distribution = {}
for (short, full) in choices:
if feature == 'gender':
count = CourseEnrollment.objects.filter(
......@@ -55,12 +114,12 @@ def profile_distribution(course_id, feature):
count = CourseEnrollment.objects.filter(
course_id=course_id, user__profile__level_of_education=short
).count()
data[short] = count
distribution[short] = count
feature_results['data'] = data
feature_results['type'] = 'EASY_CHOICE'
feature_results['display_names'] = dict(choices)
prd.data = distribution
prd.choices_display_names = dict(choices)
elif feature in _OPEN_CHOICE_FEATURES:
prd.type = 'OPEN_CHOICE'
profiles = UserProfile.objects.filter(
user__courseenrollment__course_id=course_id)
query_distribution = profiles.values(
......@@ -81,7 +140,7 @@ def profile_distribution(course_id, feature):
**{feature: None}
).count()
feature_results['data'] = distribution
feature_results['type'] = 'OPEN_CHOICE'
prd.data = distribution
return feature_results
prd.validate()
return prd
......@@ -31,20 +31,20 @@ class TestAnalyticsDistributions(TestCase):
feature = 'gender'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
self.assertEqual(distribution['type'], 'EASY_CHOICE')
self.assertEqual(distribution['data']['no_data'], 0)
self.assertEqual(distribution['data']['m'], len(self.users) / 3)
self.assertEqual(distribution['display_names']['m'], 'Male')
self.assertEqual(distribution.type, 'EASY_CHOICE')
self.assertEqual(distribution.data['no_data'], 0)
self.assertEqual(distribution.data['m'], len(self.users) / 3)
self.assertEqual(distribution.choices_display_names['m'], 'Male')
def test_profile_distribution_open_choice(self):
feature = 'year_of_birth'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution['type'], 'OPEN_CHOICE')
self.assertNotIn('display_names', distribution)
self.assertNotIn('no_data', distribution['data'])
self.assertEqual(distribution['data'][1930], 1)
self.assertEqual(distribution.type, 'OPEN_CHOICE')
self.assertFalse(hasattr(distribution, 'choices_display_names'))
self.assertNotIn('no_data', distribution.data)
self.assertEqual(distribution.data[1930], 1)
class TestAnalyticsDistributionsNoData(TestCase):
......@@ -70,7 +70,7 @@ class TestAnalyticsDistributionsNoData(TestCase):
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution['type'], 'OPEN_CHOICE')
self.assertNotIn('display_names', distribution)
self.assertIn('no_data', distribution['data'])
self.assertEqual(distribution['data']['no_data'], len(self.nodata_users))
self.assertEqual(distribution.type, 'OPEN_CHOICE')
self.assertFalse(hasattr(distribution, 'choices_display_names'))
self.assertIn('no_data', distribution.data)
self.assertEqual(distribution.data['no_data'], len(self.nodata_users))
......@@ -58,6 +58,8 @@ def _change_access(course, user, level, mode):
level is one of ['instructor', 'staff', 'beta']
mode is one of ['allow', 'revoke']
NOTE: will NOT create a group that does not yet exist.
"""
if level is 'beta':
......
"""
Unit tests for instructor.enrollment methods.
Unit tests for instructor.api methods.
"""
import json
from urllib import quote
from django.test import TestCase
from nose.tools import raises
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from instructor.views.api import _split_input_list
from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.helpers import LoginEnrollmentTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, AdminFactory
from student.models import CourseEnrollment
from instructor.views.api import _split_input_list, _msk_from_problem_urlname
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Ensure that users cannot access endpoints they shouldn't be able to.
"""
def setUp(self):
self.user = UserFactory.create()
self.course = CourseFactory.create()
CourseEnrollment.objects.create(user=self.user, course_id=self.course.id)
self.client.login(username=self.user.username, password='test')
def test_deny_students_update_enrollment(self):
url = reverse('students_update_enrollment', kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
self.assertEqual(response.status_code, 403)
def test_staff_level(self):
"""
Ensure that an enrolled student can't access staff or instructor endpoints.
"""
staff_level_endpoints = [
'students_update_enrollment',
'modify_access',
'list_course_role_members',
'get_grading_config',
'get_students_features',
'get_distribution',
'get_student_progress_url',
'reset_student_attempts',
'rescore_problem',
'list_instructor_tasks',
'list_forum_members',
'update_forum_role_membership',
]
for endpoint in staff_level_endpoints:
url = reverse(endpoint, kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
self.assertEqual(response.status_code, 403)
def test_instructor_level(self):
"""
Ensure that a staff member can't access instructor endpoints.
"""
instructor_level_endpoints = [
'modify_access',
'list_course_role_members',
'reset_student_attempts',
'list_instructor_tasks',
'update_forum_role_membership',
]
for endpoint in instructor_level_endpoints:
url = reverse(endpoint, kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
self.assertEqual(response.status_code, 403)
# class TestInstructorAPILevelsEnrollment
# # students_update_enrollment
# class TestInstructorAPILevelsAccess
# # modify_access
# # list_course_role_members
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test endpoints that show data without side effects.
"""
def setUp(self):
self.instructor = AdminFactory.create()
self.course = CourseFactory.create()
self.client.login(username=self.instructor.username, password='test')
# self.students = [UserFactory(email="foobar{}@robot.org".format(i)) for i in xrange(6)]
self.students = [UserFactory() for _ in xrange(6)]
for student in self.students:
CourseEnrollment.objects.create(user=student, course_id=self.course.id)
def test_get_students_features(self):
"""
Test that some minimum of information is formatted
correctly in the response to get_students_features.
"""
url = reverse('get_students_features', kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
res_json = json.loads(response.content)
self.assertIn('students', res_json)
for student in self.students:
student_json = [
x for x in res_json['students']
if x['username'] == student.username
][0]
self.assertEqual(student_json['username'], student.username)
self.assertEqual(student_json['email'], student.email)
def test_get_distribution_no_feature(self):
"""
Test that get_distribution lists available features
when supplied no feature quparameter.
"""
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertEqual(type(res_json['available_features']), list)
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url + u'?feature=')
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertEqual(type(res_json['available_features']), list)
def test_get_distribution_unavailable_feature(self):
"""
Test that get_distribution fails gracefully with
an unavailable feature.
"""
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url, {'feature': 'robot-not-a-real-feature'})
self.assertEqual(response.status_code, 400)
def test_get_distribution_gender(self):
"""
Test that get_distribution fails gracefully with
an unavailable feature.
"""
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url, {'feature': 'gender'})
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
print res_json
self.assertEqual(res_json['feature_results']['data']['m'], 6)
self.assertEqual(res_json['feature_results']['choices_display_names']['m'], 'Male')
self.assertEqual(res_json['feature_results']['data']['no_data'], 0)
self.assertEqual(res_json['feature_results']['choices_display_names']['no_data'], 'No Data')
def test_get_student_progress_url(self):
""" Test that progress_url is in the successful response. """
url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id})
url += "?student_email={}".format(
quote(self.students[0].email.encode("utf-8"))
)
print url
response = self.client.get(url)
print response
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertIn('progress_url', res_json)
def test_get_student_progress_url_noparams(self):
""" Test that the endpoint 404's without the required query params. """
url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
def test_get_student_progress_url_nostudent(self):
""" Test that the endpoint 400's when requesting an unknown email. """
url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
# class TestInstructorAPILevelsGrade modification & tasks
# # reset_student_attempts
# # rescore_problem
# # list_instructor_tasks
# class TestInstructorAPILevelsForums
# # list_forum_members
# # update_forum_role_membership
class TestInstructorAPIHelpers(TestCase):
......@@ -24,3 +208,13 @@ class TestInstructorAPIHelpers(TestCase):
self.assertEqual(_split_input_list(u'robot@robot.edu, robot2@robot.edu'), [u'robot@robot.edu', 'robot2@robot.edu'])
scary_unistuff = unichr(40960) + u'abcd' + unichr(1972)
self.assertEqual(_split_input_list(scary_unistuff), [scary_unistuff])
def test_msk_from_problem_urlname(self):
args = ('MITx/6.002x/2013_Spring', 'L2Node1')
output = 'i4x://MITx/6.002x/problem/L2Node1'
self.assertEqual(_msk_from_problem_urlname(*args), output)
@raises(ValueError)
def test_msk_from_problem_urlname_error(self):
args = ('notagoodcourse', 'L2Node1')
_msk_from_problem_urlname(*args)
......@@ -108,7 +108,7 @@ def _section_membership(course_id, access):
'enroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'unenroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'list_course_role_members_url': reverse('list_course_role_members', kwargs={'course_id': course_id}),
'access_allow_revoke_url': reverse('access_allow_revoke', kwargs={'course_id': course_id}),
'modify_access_url': reverse('modify_access', kwargs={'course_id': course_id}),
'list_forum_members_url': reverse('list_forum_members', kwargs={'course_id': course_id}),
'update_forum_role_membership_url': reverse('update_forum_role_membership', kwargs={'course_id': course_id}),
}
......@@ -121,7 +121,7 @@ def _section_student_admin(course_id, access):
'section_key': 'student_admin',
'section_display_name': 'Student Admin',
'access': access,
'get_student_progress_url': reverse('get_student_progress_url', kwargs={'course_id': course_id}),
'get_student_progress_url_url': reverse('get_student_progress_url', kwargs={'course_id': course_id}),
'enrollment_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'reset_student_attempts_url': reverse('reset_student_attempts', kwargs={'course_id': course_id}),
'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': course_id}),
......@@ -135,8 +135,8 @@ def _section_data_download(course_id):
section_data = {
'section_key': 'data_download',
'section_display_name': 'Data Download',
'grading_config_url': reverse('grading_config', kwargs={'course_id': course_id}),
'enrolled_students_features_url': reverse('enrolled_students_features', 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}),
}
return section_data
......@@ -146,6 +146,6 @@ def _section_analytics(course_id):
section_data = {
'section_key': 'analytics',
'section_display_name': 'Analytics',
'profile_distributions_url': reverse('profile_distribution', kwargs={'course_id': course_id}),
'get_distribution_url': reverse('get_distribution', kwargs={'course_id': course_id}),
}
return section_data
......@@ -29,6 +29,8 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True
......
......@@ -28,7 +28,8 @@ class Analytics
# fetch and list available distributions
# `cb` is a callback to be run after
populate_selector: (cb) ->
@get_profile_distributions [],
# ask for no particular distribution to get list of available distribuitions.
@get_profile_distributions undefined,
# on error, print to console and dom.
error: std_ajax_err => @$request_response_error.text "Error getting available distributions."
success: (data) =>
......@@ -38,7 +39,7 @@ class Analytics
# add all fetched available features to drop-down
for feature in data.available_features
opt = $ '<option/>',
text: data.display_names[feature]
text: data.feature_display_names[feature]
data:
feature: feature
......@@ -53,16 +54,12 @@ class Analytics
feature = opt.data 'feature'
@reset_display()
# only proceed if there is a feature attached to the selected option.
return unless feature
@get_profile_distributions [feature],
@get_profile_distributions feature,
error: std_ajax_err => @$request_response_error.text "Error getting distribution for '#{feature}'."
success: (data) =>
feature_res = data.feature_results[feature]
# feature response format: {'error': 'optional error string', 'type': 'SOME_TYPE', 'data': [stuff]}
if feature_res.error
console.warn(feature_res.error)
@$display_text.text 'Error fetching data'
else
feature_res = data.feature_results
if feature_res.type is 'EASY_CHOICE'
# display on SlickGrid
options =
......@@ -82,14 +79,14 @@ class Analytics
grid_data = _.map feature_res.data, (value, key) ->
datapoint = {}
datapoint[feature] = feature_res.display_names[key]
datapoint[feature] = feature_res.choices_display_names[key]
datapoint['count'] = value
datapoint
table_placeholder = $ '<div/>', class: 'slickgrid'
@$display_table.append table_placeholder
grid = new Slick.Grid(table_placeholder, grid_data, columns, options)
else if feature is 'year_of_birth'
else if feature_res.feature is 'year_of_birth'
graph_placeholder = $ '<div/>', class: 'year-of-birth'
@$display_graph.append graph_placeholder
......@@ -99,18 +96,18 @@ class Analytics
data: graph_data
]
else
console.warn("don't know how to show #{feature_res.type}")
@$display_text.text 'Unavailable Metric\n' + JSON.stringify(feature_res)
console.warn("unable to show distribution #{feature_res.type}")
@$display_text.text 'Unavailable Metric Display\n' + JSON.stringify(feature_res)
# fetch distribution data from server.
# `handler` can be either a callback for success
# or a mapping e.g. {success: ->, error: ->, complete: ->}
get_profile_distributions: (featurelist, handler) ->
get_profile_distributions: (feature, handler) ->
settings =
dataType: 'json'
url: @$distribution_select.data 'endpoint'
data: features: JSON.stringify featurelist
data: feature: feature
if typeof handler is 'function'
_.extend settings, success: handler
......
......@@ -36,9 +36,7 @@ HASH_LINK_PREFIX = '#view-'
# once we're ready, check if this page is the instructor dashboard
$ =>
instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}"
console.log 'checking if we are on the instructor dashboard'
if instructor_dashboard_content.length > 0
console.log 'we are on the instructor dashboard'
setup_instructor_dashboard instructor_dashboard_content
setup_instructor_dashboard_sections instructor_dashboard_content
......
<%page args="section_data"/>
<h2>Distributions</h2>
<select id="distributions" data-endpoint="${ section_data['profile_distributions_url'] }">
<select id="distributions" data-endpoint="${ section_data['get_distribution_url'] }">
<option> Getting available distributions... </option>
</select>
<div class="distribution-display">
......
<%page args="section_data"/>
<input type="button" name="list-profiles" value="List enrolled students with profile information" data-endpoint="${ section_data['enrolled_students_features_url'] }" >
<input type="button" name="list-profiles" value="CSV" data-csv="true" class="csv" data-endpoint="${ section_data['enrolled_students_features_url'] }" >
<input type="button" name="list-profiles" value="List enrolled students with profile information" data-endpoint="${ section_data['get_students_features_url'] }" >
<input type="button" name="list-profiles" value="CSV" data-csv="true" class="csv" data-endpoint="${ section_data['get_students_features_url'] }" >
<br>
## <input type="button" name="list-grades" value="Student grades">
## <input type="button" name="list-profiles" value="CSV" data-csv="true" class="csv">
## <br>
## <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['grading_config_url'] }">
<input type="button" name="dump-gradeconf" value="Grading Configuration" data-endpoint="${ section_data['get_grading_config_url'] }">
<div class="data-display">
<div class="data-display-text"></div>
......
......@@ -29,7 +29,7 @@
%if section_data['access']['instructor']:
<div class="auth-list-container" data-rolename="staff" data-display-name="Staff">
<div class="auth-list-table" data-endpoint="${ section_data['list_course_role_members_url'] }"></div>
<div class="auth-list-add" data-endpoint="${ section_data['access_allow_revoke_url'] }">
<div class="auth-list-add" data-endpoint="${ section_data['modify_access_url'] }">
<input type="text" name="email" placeholder="Enter Email" spellcheck="false">
<input type="button" name="allow" value="Grant Staff Access">
</div>
......@@ -39,7 +39,7 @@
%if section_data['access']['instructor']:
<div class="auth-list-container" data-rolename="instructor" data-display-name="Instructors">
<div class="auth-list-table" data-endpoint="${ section_data['list_course_role_members_url'] }"></div>
<div class="auth-list-add" data-endpoint="${ section_data['access_allow_revoke_url'] }">
<div class="auth-list-add" data-endpoint="${ section_data['modify_access_url'] }">
<input type="text" name="email" placeholder="Enter Email" spellcheck="false">
<input type="button" name="allow" value="Grant Instructor Access">
</div>
......@@ -49,7 +49,7 @@
<div class="auth-list-container" data-rolename="beta" data-display-name="Beta Testers">
<div class="auth-list-table" data-endpoint="${ section_data['list_course_role_members_url'] }"></div>
<div class="auth-list-add" data-endpoint="${ section_data['access_allow_revoke_url'] }">
<div class="auth-list-add" data-endpoint="${ section_data['modify_access_url'] }">
<input type="text" name="email" placeholder="Enter Email" spellcheck="false">
<input type="button" name="allow" value="Grant Beta Tester Access">
</div>
......
......@@ -8,7 +8,7 @@
<br>
<div class="progress-link-wrapper">
<a href="" class="progress-link" data-endpoint="${ section_data['get_student_progress_url'] }">Student Progress Page</a>
<a href="" class="progress-link" data-endpoint="${ section_data['get_student_progress_url_url'] }">Student Progress Page</a>
</div>
<br>
......
......@@ -350,14 +350,14 @@ if settings.COURSEWARE_ENABLED and settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR
'instructor.views.api.students_update_enrollment', name="students_update_enrollment"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/list_course_role_members$',
'instructor.views.api.list_course_role_members', name="list_course_role_members"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/access_allow_revoke$',
'instructor.views.api.access_allow_revoke', name="access_allow_revoke"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/grading_config$',
'instructor.views.api.grading_config', name="grading_config"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/enrolled_students_features(?P<csv>/csv)?$',
'instructor.views.api.enrolled_students_features', name="enrolled_students_features"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/profile_distribution$',
'instructor.views.api.profile_distribution', name="profile_distribution"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/modify_access$',
'instructor.views.api.modify_access', name="modify_access"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/get_grading_config$',
'instructor.views.api.get_grading_config', name="get_grading_config"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/get_students_features(?P<csv>/csv)?$',
'instructor.views.api.get_students_features', name="get_students_features"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/get_distribution$',
'instructor.views.api.get_distribution', name="get_distribution"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/get_student_progress_url$',
'instructor.views.api.get_student_progress_url', name="get_student_progress_url"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/reset_student_attempts$',
......
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