Commit 2760938e by Miles Steele

fix analytics distribution with no data, add unit test, cleanup

parent f7989cb2
...@@ -36,7 +36,7 @@ def enrolled_students_features(course_id, features): ...@@ -36,7 +36,7 @@ def enrolled_students_features(course_id, features):
student_dict = dict((feature, getattr(student, feature)) student_dict = dict((feature, getattr(student, feature))
for feature in student_features) for feature in student_features)
profile = student.profile profile = student.profile
if not profile is None: if profile is not None:
profile_dict = dict((feature, getattr(profile, feature)) profile_dict = dict((feature, getattr(profile, feature))
for feature in profile_features) for feature in profile_features)
student_dict.update(profile_dict) student_dict.update(profile_dict)
......
...@@ -24,8 +24,11 @@ The distribution in a course for gender might look like: ...@@ -24,8 +24,11 @@ The distribution in a course for gender might look like:
from django.db.models import Count from django.db.models import Count
from student.models import CourseEnrollment, UserProfile from student.models import CourseEnrollment, UserProfile
# choices with a restricted domain, e.g. level_of_education
_EASY_CHOICE_FEATURES = ('gender', 'level_of_education') _EASY_CHOICE_FEATURES = ('gender', 'level_of_education')
# choices with a larger domain e.g. year_of_birth
_OPEN_CHOICE_FEATURES = ('year_of_birth',) _OPEN_CHOICE_FEATURES = ('year_of_birth',)
AVAILABLE_PROFILE_FEATURES = _EASY_CHOICE_FEATURES + _OPEN_CHOICE_FEATURES AVAILABLE_PROFILE_FEATURES = _EASY_CHOICE_FEATURES + _OPEN_CHOICE_FEATURES
DISPLAY_NAMES = { DISPLAY_NAMES = {
'gender': 'Gender', 'gender': 'Gender',
...@@ -51,7 +54,7 @@ class ProfileDistribution(object): ...@@ -51,7 +54,7 @@ class ProfileDistribution(object):
def __init__(self, feature): def __init__(self, feature):
self.feature = feature self.feature = feature
self.feature_display_name = DISPLAY_NAMES[feature] self.feature_display_name = DISPLAY_NAMES.get(feature, feature)
def validate(self): def validate(self):
""" """
...@@ -64,6 +67,7 @@ class ProfileDistribution(object): ...@@ -64,6 +67,7 @@ class ProfileDistribution(object):
raise ProfileDistribution.ValidationError() raise ProfileDistribution.ValidationError()
validation_assert(isinstance(self.feature, str)) validation_assert(isinstance(self.feature, str))
validation_assert(self.feature in DISPLAY_NAMES)
validation_assert(isinstance(self.feature_display_name, str)) validation_assert(isinstance(self.feature_display_name, str))
validation_assert(self.type in ['EASY_CHOICE', 'OPEN_CHOICE']) validation_assert(self.type in ['EASY_CHOICE', 'OPEN_CHOICE'])
validation_assert(isinstance(self.data, dict)) validation_assert(isinstance(self.data, dict))
...@@ -78,10 +82,8 @@ def profile_distribution(course_id, feature): ...@@ -78,10 +82,8 @@ def profile_distribution(course_id, feature):
Returns a ProfileDistribution instance. Returns a ProfileDistribution instance.
NOTE: no_data will appear as a key instead of None to be compatible with the json spec. NOTE: no_data will appear as a key instead of None/null to adhere to the json spec.
data types are data types are EASY_CHOICE or OPEN_CHOICE
EASY_CHOICE - choices with a restricted domain, e.g. level_of_education
OPEN_CHOICE - choices with a larger domain e.g. year_of_birth
""" """
if not feature in AVAILABLE_PROFILE_FEATURES: if not feature in AVAILABLE_PROFILE_FEATURES:
...@@ -100,28 +102,41 @@ def profile_distribution(course_id, feature): ...@@ -100,28 +102,41 @@ def profile_distribution(course_id, feature):
elif feature == 'level_of_education': elif feature == 'level_of_education':
raw_choices = UserProfile.LEVEL_OF_EDUCATION_CHOICES raw_choices = UserProfile.LEVEL_OF_EDUCATION_CHOICES
# short name and display nae (full) of the choices. # short name and display name (full) of the choices.
choices = [(short, full) choices = [(short, full)
for (short, full) in raw_choices] + [('no_data', 'No Data')] for (short, full) in raw_choices] + [('no_data', 'No Data')]
def get_filter(feature, value):
""" Get the orm filter parameters for a feature. """
return {
'gender': {'user__profile__gender': value},
'level_of_education': {'user__profile__level_of_education': value},
}[feature]
def get_count(feature, value):
""" Get the count of enrolled students matching the feature value. """
return CourseEnrollment.objects.filter(
course_id=course_id,
**get_filter(feature, value)
).count()
distribution = {} distribution = {}
for (short, full) in choices: for (short, full) in choices:
if feature == 'gender': # handle no data case
count = CourseEnrollment.objects.filter( if short == 'no_data':
course_id=course_id, user__profile__gender=short distribution['no_data'] = 0
).count() distribution['no_data'] += get_count(feature, None)
elif feature == 'level_of_education': distribution['no_data'] += get_count(feature, '')
count = CourseEnrollment.objects.filter( else:
course_id=course_id, user__profile__level_of_education=short distribution[short] = get_count(feature, short)
).count()
distribution[short] = count
prd.data = distribution prd.data = distribution
prd.choices_display_names = dict(choices) prd.choices_display_names = dict(choices)
elif feature in _OPEN_CHOICE_FEATURES: elif feature in _OPEN_CHOICE_FEATURES:
prd.type = 'OPEN_CHOICE' prd.type = 'OPEN_CHOICE'
profiles = UserProfile.objects.filter( profiles = UserProfile.objects.filter(
user__courseenrollment__course_id=course_id) user__courseenrollment__course_id=course_id
)
query_distribution = profiles.values( query_distribution = profiles.values(
feature).annotate(Count(feature)).order_by() feature).annotate(Count(feature)).order_by()
# query_distribution is of the form [{'featureval': 'value1', 'featureval__count': 4}, # query_distribution is of the form [{'featureval': 'value1', 'featureval__count': 4},
...@@ -133,9 +148,12 @@ def profile_distribution(course_id, feature): ...@@ -133,9 +148,12 @@ def profile_distribution(course_id, feature):
# change none to no_data for valid json key # change none to no_data for valid json key
if None in distribution: if None in distribution:
distribution['no_data'] = distribution.pop(None) # django does not properly count NULL values when using annotate Count
# django does not properly count NULL values, so the above will alwasy be 0. # so
# this correctly counts null values # distribution['no_data'] = distribution.pop(None)
# would always be 0.
# Correctly count null values
distribution['no_data'] = profiles.filter( distribution['no_data'] = profiles.filter(
**{feature: None} **{feature: None}
).count() ).count()
......
...@@ -19,7 +19,10 @@ class TestAnalyticsDistributions(TestCase): ...@@ -19,7 +19,10 @@ class TestAnalyticsDistributions(TestCase):
profile__year_of_birth=i + 1930 profile__year_of_birth=i + 1930
) for i in xrange(30)] ) for i in xrange(30)]
self.ces = [CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users] self.ces = [CourseEnrollment.objects.create(
course_id=self.course_id,
user=user
) for user in self.users]
@raises(ValueError) @raises(ValueError)
def test_profile_distribution_bad_feature(self): def test_profile_distribution_bad_feature(self):
...@@ -59,12 +62,23 @@ class TestAnalyticsDistributionsNoData(TestCase): ...@@ -59,12 +62,23 @@ class TestAnalyticsDistributionsNoData(TestCase):
self.nodata_users = [UserFactory( self.nodata_users = [UserFactory(
profile__year_of_birth=None, profile__year_of_birth=None,
) for _ in xrange(4)] profile__gender=[None, ''][i % 2]
) for i in xrange(4)]
self.users += self.nodata_users self.users += self.nodata_users
self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users) self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users)
def test_profile_distribution_easy_choice_nodata(self):
feature = 'gender'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution.type, 'EASY_CHOICE')
self.assertTrue(hasattr(distribution, 'choices_display_names'))
self.assertIn('no_data', distribution.data)
self.assertEqual(distribution.data['no_data'], len(self.nodata_users))
def test_profile_distribution_open_choice_nodata(self): def test_profile_distribution_open_choice_nodata(self):
feature = 'year_of_birth' feature = 'year_of_birth'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES) self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
......
...@@ -22,7 +22,7 @@ def list_with_level(course, level): ...@@ -22,7 +22,7 @@ def list_with_level(course, level):
""" """
List users who have 'level' access. List users who have 'level' access.
level is in ['instructor', 'staff', 'beta'] for standard courses. `level` is in ['instructor', 'staff', 'beta'] for standard courses.
There could be other levels specific to the course. There could be other levels specific to the course.
If there is no Group for that course-level, returns an empty list If there is no Group for that course-level, returns an empty list
""" """
...@@ -42,7 +42,7 @@ def allow_access(course, user, level): ...@@ -42,7 +42,7 @@ def allow_access(course, user, level):
""" """
Allow user access to course modification. Allow user access to course modification.
level is one of ['instructor', 'staff', 'beta'] `level` is one of ['instructor', 'staff', 'beta']
""" """
_change_access(course, user, level, 'allow') _change_access(course, user, level, 'allow')
...@@ -51,7 +51,7 @@ def revoke_access(course, user, level): ...@@ -51,7 +51,7 @@ def revoke_access(course, user, level):
""" """
Revoke access from user to course modification. Revoke access from user to course modification.
level is one of ['instructor', 'staff', 'beta'] `level` is one of ['instructor', 'staff', 'beta']
""" """
_change_access(course, user, level, 'revoke') _change_access(course, user, level, 'revoke')
...@@ -60,7 +60,7 @@ def _change_access(course, user, level, action): ...@@ -60,7 +60,7 @@ def _change_access(course, user, level, action):
""" """
Change access of user. Change access of user.
level is one of ['instructor', 'staff', 'beta'] `level` is one of ['instructor', 'staff', 'beta']
action is one of ['allow', 'revoke'] action is one of ['allow', 'revoke']
NOTE: will create a group if it does not yet exist. NOTE: will create a group if it does not yet exist.
......
...@@ -49,7 +49,6 @@ def instructor_dashboard_2(request, course_id): ...@@ -49,7 +49,6 @@ def instructor_dashboard_2(request, course_id):
context = { context = {
'course': course, 'course': course,
# 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}),
'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}), 'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}),
'sections': sections, 'sections': sections,
} }
...@@ -83,7 +82,9 @@ def _section_course_info(course_id): ...@@ -83,7 +82,9 @@ def _section_course_info(course_id):
section_data['has_started'] = course.has_started() section_data['has_started'] = course.has_started()
section_data['has_ended'] = course.has_ended() section_data['has_ended'] = course.has_ended()
try: try:
section_data['grade_cutoffs'] = "" + reduce(lambda memo, (letter, score): "{}: {}, ".format(letter, score) + memo, course.grade_cutoffs.items(), "")[:-2] + "" def next(memo, (letter, score)):
return "{}: {}, ".format(letter, score) + memo
section_data['grade_cutoffs'] = reduce(next, course.grade_cutoffs.items(), "")[:-2]
except: except:
section_data['grade_cutoffs'] = "Not Available" section_data['grade_cutoffs'] = "Not Available"
# section_data['offline_grades'] = offline_grades_available(course_id) # section_data['offline_grades'] = offline_grades_available(course_id)
......
...@@ -156,9 +156,6 @@ MITX_FEATURES = { ...@@ -156,9 +156,6 @@ MITX_FEATURES = {
# Toggle to enable chat availability (configured on a per-course # Toggle to enable chat availability (configured on a per-course
# basis in Studio) # basis in Studio)
'ENABLE_CHAT': False, 'ENABLE_CHAT': False,
# Enable instructor dash to submit background tasks
'ENABLE_INSTRUCTOR_BETA_DASHBOARD': False,
} }
# Used for A/B testing # Used for A/B testing
......
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