diff --git a/lms/djangoapps/analytics/basic.py b/lms/djangoapps/analytics/basic.py index 9acb505c4e..eae293167a 100644 --- a/lms/djangoapps/analytics/basic.py +++ b/lms/djangoapps/analytics/basic.py @@ -8,8 +8,9 @@ from django.contrib.auth.models import User import xmodule.graders as xmgraders -AVAILABLE_STUDENT_FEATURES = ['username', 'first_name', 'last_name', 'is_staff', 'email'] -AVAILABLE_PROFILE_FEATURES = ['name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals'] +STUDENT_FEATURES = ('username', 'first_name', 'last_name', 'is_staff', 'email') +PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals') +AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES def enrolled_students_profiles(course_id, features): @@ -19,10 +20,12 @@ def enrolled_students_profiles(course_id, features): # enrollments = CourseEnrollment.objects.filter(course_id=course_id) # students = [enrollment.user for enrollment in enrollments] students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related('profile') + print len(students) + print students def extract_student(student): - student_features = [feature for feature in features if feature in AVAILABLE_STUDENT_FEATURES] - profile_features = [feature for feature in features if feature in AVAILABLE_PROFILE_FEATURES] + student_features = [feature for feature in features if feature in STUDENT_FEATURES] + profile_features = [feature for feature in features if feature in PROFILE_FEATURES] student_dict = dict((feature, getattr(student, feature)) for feature in student_features) profile = student.profile @@ -35,7 +38,7 @@ def enrolled_students_profiles(course_id, features): def dump_grading_context(course): """ - Dump information about course grading context (eg which problems are graded in what assignments) + Render information about course grading context (eg which problems are graded in what assignments) Useful for debugging grading_policy.json and policy.json Returns HTML string diff --git a/lms/djangoapps/analytics/csvs.py b/lms/djangoapps/analytics/csvs.py index ece486644f..5a2ff18af3 100644 --- a/lms/djangoapps/analytics/csvs.py +++ b/lms/djangoapps/analytics/csvs.py @@ -27,30 +27,34 @@ def create_csv_response(filename, header, datarows): def format_dictlist(dictlist): """ - Convert from [ + Convert FROM [ { - 'label1': 'value1,1', - 'label2': 'value2,1', - 'label3': 'value3,1', - 'label4': 'value4,1', + 'label1': 'value-1,1', + 'label2': 'value-1,2', + 'label3': 'value-1,3', + 'label4': 'value-1,4', }, { - 'label1': 'value1,2', - 'label2': 'value2,2', - 'label3': 'value3,2', - 'label4': 'value4,2', + 'label1': 'value-2,1', + 'label2': 'value-2,2', + 'label3': 'value-2,3', + 'label4': 'value-2,4', } ] - to { + TO { 'header': ['label1', 'label2', 'label3', 'label4'], - 'datarows': ['value1,1', 'value2,1', 'value3,1', 'value4,1'], ['value1,2', 'value2,2', 'value3,2', 'value4,2'] + 'datarows': [['value-1,1', 'value-1,2', 'value-1,3', 'value-1,4'], + ['value-2,1', 'value-2,2', 'value-2,3', 'value-2,4']] } - Do not handle empty lists. + Assumes all keys for input dicts are the same. """ - header = dictlist[0].keys() + if len(dictlist) > 0: + header = dictlist[0].keys() + else: + header = [] def dict_to_entry(d): ordered = sorted(d.items(), key=lambda (k, v): header.index(k)) diff --git a/lms/djangoapps/analytics/distributions.py b/lms/djangoapps/analytics/distributions.py index 6e4babb871..c06e7bdcb0 100644 --- a/lms/djangoapps/analytics/distributions.py +++ b/lms/djangoapps/analytics/distributions.py @@ -14,7 +14,9 @@ def profile_distribution(course_id, feature): Retrieve distribution of students over a given feature. feature is one of AVAILABLE_PROFILE_FEATURES. - Returna dictionary {'type': 'SOME_TYPE', 'data': {'key': 'val'}} + Returna dictionary {'type': 'SOME_TYPE', 'data': {'key': 'val'}, 'display_names': {'key': 'displaynameval'}} + 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. EASY_CHOICE - choices with a restricted domain, e.g. level_of_education OPEN_CHOICE - choices with a larger domain e.g. year_of_birth @@ -23,18 +25,23 @@ def profile_distribution(course_id, feature): EASY_CHOICE_FEATURES = ['gender', 'level_of_education'] OPEN_CHOICE_FEATURES = ['year_of_birth'] + def raise_not_implemented(): + raise NotImplementedError("feature requested not implemented but is advertised in AVAILABLE_PROFILE_FEATURES {}" .format(feature)) + feature_results = {} if not feature in AVAILABLE_PROFILE_FEATURES: - raise ValueError("unsupported feature requested for distribution '%s'" % feature) + raise ValueError("unsupported feature requested for distribution '{}'".format(feature)) if feature in EASY_CHOICE_FEATURES: if feature == 'gender': - choices = [(short, full) for (short, full) in UserProfile.GENDER_CHOICES] + [(None, 'No Data')] + raw_choices = UserProfile.GENDER_CHOICES elif feature == 'level_of_education': - choices = [(short, full) for (short, full) in UserProfile.LEVEL_OF_EDUCATION_CHOICES] + [(None, 'No Data')] + raw_choices = UserProfile.LEVEL_OF_EDUCATION_CHOICES else: - raise ValueError("feature request not implemented for feature %s" % feature) + raise raise_not_implemented() + + choices = [(short, full) for (short, full) in raw_choices] + [('no_data', 'No Data')] data = {} for (short, full) in choices: @@ -43,21 +50,30 @@ def profile_distribution(course_id, feature): elif feature == 'level_of_education': count = CourseEnrollment.objects.filter(course_id=course_id, user__profile__level_of_education=short).count() else: - raise ValueError("feature request not implemented for feature %s" % feature) - data[full] = count + raise raise_not_implemented() + data[short] = count feature_results['data'] = data feature_results['type'] = 'EASY_CHOICE' + feature_results['display_names'] = dict(choices) elif feature in OPEN_CHOICE_FEATURES: profiles = UserProfile.objects.filter(user__courseenrollment__course_id=course_id) query_distribution = profiles.values(feature).annotate(Count(feature)).order_by() - # query_distribution is of the form [{'attribute': 'value1', 'attribute__count': 4}, {'attribute': 'value2', 'attribute__count': 2}, ...] + # query_distribution is of the form [{'featureval': 'value1', 'featureval__count': 4}, {'featureval': 'value2', 'featureval__count': 2}, ...] distribution = dict((vald[feature], vald[feature + '__count']) for vald in query_distribution) # distribution is of the form {'value1': 4, 'value2': 2, ...} + + # change none to no_data for valid json key + if None in distribution: + distribution['no_data'] = distribution.pop(None) + # django does not properly count NULL values, so the above will alwasy be 0. + # this correctly counts null values + distribution['no_data'] = profiles.filter(**{feature: None}).count() + feature_results['data'] = distribution feature_results['type'] = 'OPEN_CHOICE' else: - raise ValueError("feature requested for distribution has not been implemented but is advertised in AVAILABLE_PROFILE_FEATURES! '%s'" % feature) + raise raise_not_implemented() return feature_results diff --git a/lms/djangoapps/analytics/tests/test_basic.py b/lms/djangoapps/analytics/tests/test_basic.py new file mode 100644 index 0000000000..4278ff299c --- /dev/null +++ b/lms/djangoapps/analytics/tests/test_basic.py @@ -0,0 +1,40 @@ +from django.test import TestCase +from django.contrib.auth.models import User, Group +from student.models import CourseEnrollment +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory + +from analytics.basic import enrolled_students_profiles, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES + + +class TestAnalyticsBasic(TestCase): + '''Test basic analytics functions.''' + + def setUp(self): + self.course_id = 'some/robot/course/id' + self.users = tuple(UserFactory() for _ in xrange(30)) + self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users) + + def test_enrolled_students_profiles_username(self): + self.assertIn('username', AVAILABLE_FEATURES) + userreports = enrolled_students_profiles(self.course_id, ['username']) + self.assertEqual(len(userreports), len(self.users)) + for userreport in userreports: + self.assertEqual(userreport.keys(), ['username']) + self.assertIn(userreport['username'], [user.username for user in self.users]) + + def test_enrolled_students_profiles_keys(self): + query_features = ('username', 'name', 'email') + for feature in query_features: + self.assertIn(feature, AVAILABLE_FEATURES) + userreports = enrolled_students_profiles(self.course_id, query_features) + self.assertEqual(len(userreports), len(self.users)) + for userreport in userreports: + self.assertEqual(set(userreport.keys()), set(query_features)) + self.assertIn(userreport['username'], [user.username for user in self.users]) + self.assertIn(userreport['email'], [user.email for user in self.users]) + self.assertIn(userreport['name'], [user.profile.name for user in self.users]) + + def test_available_features(self): + self.assertEqual(len(AVAILABLE_FEATURES), len(STUDENT_FEATURES + PROFILE_FEATURES)) + self.assertEqual(set(AVAILABLE_FEATURES), set(STUDENT_FEATURES + PROFILE_FEATURES)) diff --git a/lms/djangoapps/analytics/tests/test_csvs.py b/lms/djangoapps/analytics/tests/test_csvs.py new file mode 100644 index 0000000000..220acaa0b1 --- /dev/null +++ b/lms/djangoapps/analytics/tests/test_csvs.py @@ -0,0 +1,65 @@ +from django.test import TestCase + +from analytics.csvs import create_csv_response, format_dictlist + + +class TestAnalyticsCSVS(TestCase): + '''Test analytics rendering of csv files.''' + + def test_create_csv_response_nodata(self): + header = ['Name', 'Email'] + datarows = [] + + res = create_csv_response('robot.csv', header, datarows) + self.assertEqual(res['Content-Type'], 'text/csv') + self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv')) + self.assertEqual(res.content.strip(), '"Name","Email"') + + def test_create_csv_response(self): + header = ['Name', 'Email'] + datarows = [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ['Jeeves', 'jeeves@edy.org']] + + res = create_csv_response('robot.csv', header, datarows) + self.assertEqual(res['Content-Type'], 'text/csv') + self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv')) + self.assertEqual(res.content.strip(), '"Name","Email"\r\n"Jim","jim@edy.org"\r\n"Jake","jake@edy.org"\r\n"Jeeves","jeeves@edy.org"') + + def test_create_csv_response_empty(self): + header = [] + datarows = [] + + res = create_csv_response('robot.csv', header, datarows) + self.assertEqual(res['Content-Type'], 'text/csv') + self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv')) + self.assertEqual(res.content.strip(), '') + + def test_format_dictlist(self): + data_in = [ + { + 'label1': 'value-1,1', + 'label2': 'value-1,2', + 'label3': 'value-1,3', + 'label4': 'value-1,4', + }, + { + 'label1': 'value-2,1', + 'label2': 'value-2,2', + 'label3': 'value-2,3', + 'label4': 'value-2,4', + }, + ] + + data_out = { + 'header': ['label1', 'label2', 'label3', 'label4'], + 'datarows': [['value-1,1', 'value-1,2', 'value-1,3', 'value-1,4'], + ['value-2,1', 'value-2,2', 'value-2,3', 'value-2,4']], + } + + self.assertEqual(format_dictlist(data_in), data_out) + + + def test_format_dictlist_empty(self): + self.assertEqual(format_dictlist([]), { + 'header': [], + 'datarows': [], + }) diff --git a/lms/djangoapps/analytics/tests/test_distributions.py b/lms/djangoapps/analytics/tests/test_distributions.py new file mode 100644 index 0000000000..3eb509c01b --- /dev/null +++ b/lms/djangoapps/analytics/tests/test_distributions.py @@ -0,0 +1,83 @@ +from django.test import TestCase +from nose.tools import raises +from django.contrib.auth.models import User, Group +from student.models import CourseEnrollment +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory + +from analytics.distributions import profile_distribution, AVAILABLE_PROFILE_FEATURES + + +class TestAnalyticsDistributions(TestCase): + '''Test analytics distribution gathering.''' + + def setUp(self): + self.course_id = 'some/robot/course/id' + + self.users = tuple(UserFactory( + profile__gender=['m', 'f', 'o'][i % 3], + profile__year_of_birth=i + 1930 + ) for i in xrange(30)) + + self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users) + + @raises(ValueError) + def test_profile_distribution_bad_feature(self): + feature = 'robot-not-a-real-feature' + self.assertNotIn(feature, AVAILABLE_PROFILE_FEATURES) + profile_distribution(self.course_id, feature) + + @raises(NotImplementedError) + def test_profile_distribution_not_implemented_feature(self): + feature = 'ROBOT_DO_NOT_USE_FEATURE' + AVAILABLE_PROFILE_FEATURES.append(feature) + self.assertIn(feature, AVAILABLE_PROFILE_FEATURES) + profile_distribution(self.course_id, feature) + + def test_profile_distribution_easy_choice(self): + 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') + + 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) + + +class TestAnalyticsDistributionsNoData(TestCase): + '''Test analytics distribution gathering.''' + + def setUp(self): + self.course_id = 'some/robot/course/id' + + self.users = tuple(UserFactory( + profile__year_of_birth=i + 1930, + ) for i in xrange(5)) + + self.nodata_users = tuple(UserFactory( + profile__year_of_birth=None, + ) for _ in xrange(4)) + + self.users += self.nodata_users + + self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users) + + def test_profile_distribution_open_choice_nodata(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.assertIn('no_data', distribution['data']) + self.assertEqual(distribution['data']['no_data'], len(self.nodata_users)) diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index 704dfc4178..43326b325f 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -88,7 +88,5 @@ def update_forum_role_membership(course_id, user, rolename, mode): role.users.add(user) elif mode == 'revoke': role.users.remove(user) - print "\n" * 5 - print role.users.all() else: raise ValueError("unrecognized mode '{}'".format(mode)) diff --git a/lms/djangoapps/instructor/tests/test_access.py b/lms/djangoapps/instructor/tests/test_access.py new file mode 100644 index 0000000000..a5e2a9a304 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_access.py @@ -0,0 +1,217 @@ +from django.test import TestCase +from nose.tools import raises +from django.contrib.auth.models import User, Group +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from django.test.utils import override_settings +from django.conf import settings +from uuid import uuid4 + +from student.models import CourseEnrollment, CourseEnrollmentAllowed +from courseware.access import get_access_group_name +from django_comment_common.models import (Role, + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA) +from instructor.access import allow_access, revoke_access, list_with_level, update_forum_role_membership + +# mock dependency +# get_access_group_name = lambda course, role: '{0}_{1}'.format(course.course_id, role) + + +# moved here from old courseware/tests/tests.py +# when it disappeared this test broke. +def mongo_store_config(data_dir): + ''' + Defines default module store using MongoModuleStore + + Use of this config requires mongo to be running + ''' + store = { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } + } + } + store['direct'] = store['default'] + return store + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +# TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestInstructorAccessControlDB(ModuleStoreTestCase): + '''Test instructor access administration against database effects''' + + def setUp(self): + # self.course_id = 'jus:/a/fake/c::rse/id' + # self.course = MockCourse('jus:/a/fake/c::rse/id') + self.course = CourseFactory.create() + + def test_allow(self): + user = UserFactory() + level = 'staff' + + allow_access(self.course, user, level) + + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_allow_twice(self): + user = UserFactory() + level = 'staff' + + allow_access(self.course, user, level) + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + allow_access(self.course, user, level) + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_allow_revoke(self): + user = UserFactory() + level = 'staff' + + allow_access(self.course, user, level) + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + revoke_access(self.course, user, level) + self.assertNotIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + allow_access(self.course, user, level) + self.assertIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + revoke_access(self.course, user, level) + self.assertNotIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_revoke_without_group(self): + user = UserFactory() + level = 'staff' + + revoke_access(self.course, user, level) + self.assertNotIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_revoke_with_group(self): + user = UserFactory() + level = 'staff' + + Group(name=get_access_group_name(self.course, level)) + revoke_access(self.course, user, level) + self.assertNotIn(user, Group.objects.get(name=get_access_group_name(self.course, 'staff')).user_set.all()) + + def test_allow_disallow_multiuser(self): + users = [UserFactory() for _ in xrange(3)] + levels = ['staff', 'instructor', 'staff'] + antilevels = ['instructor', 'staff', 'instructor'] + + allow_access(self.course, users[0], levels[0]) + allow_access(self.course, users[1], levels[1]) + allow_access(self.course, users[2], levels[2]) + self.assertIn(users[0], Group.objects.get(name=get_access_group_name(self.course, levels[0])).user_set.all()) + self.assertIn(users[1], Group.objects.get(name=get_access_group_name(self.course, levels[1])).user_set.all()) + self.assertIn(users[2], Group.objects.get(name=get_access_group_name(self.course, levels[2])).user_set.all()) + + revoke_access(self.course, users[0], levels[0]) + revoke_access(self.course, users[0], antilevels[0]) + self.assertNotIn(users[0], Group.objects.get(name=get_access_group_name(self.course, levels[0])).user_set.all()) + self.assertIn(users[1], Group.objects.get(name=get_access_group_name(self.course, levels[1])).user_set.all()) + self.assertIn(users[2], Group.objects.get(name=get_access_group_name(self.course, levels[2])).user_set.all()) + + revoke_access(self.course, users[1], levels[1]) + allow_access(self.course, users[0], antilevels[0]) + self.assertNotIn(users[0], Group.objects.get(name=get_access_group_name(self.course, levels[0])).user_set.all()) + self.assertIn(users[0], Group.objects.get(name=get_access_group_name(self.course, antilevels[0])).user_set.all()) + self.assertNotIn(users[1], Group.objects.get(name=get_access_group_name(self.course, levels[1])).user_set.all()) + self.assertIn(users[2], Group.objects.get(name=get_access_group_name(self.course, levels[2])).user_set.all()) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestInstructorAccessControlPrefilledDB(ModuleStoreTestCase): + def setUp(self): + self.course = CourseFactory.create() + + # setup instructors + self.instructors = set([UserFactory.create(), UserFactory.create()]) + [allow_access(self.course, user, 'instructor') for user in self.instructors] + + def test_list_with_level(self): + instructors = set(list_with_level(self.course, 'instructor')) + self.assertEqual(instructors, self.instructors) + + def test_list_with_level_not_yet_group(self): + instructors = set(list_with_level(self.course, 'staff')) + self.assertEqual(instructors, set()) + + def test_list_with_level_bad_group(self): + self.assertEqual(set(list_with_level(self.course, 'robot-definitely-not-a-group')), set()) + + def test_list_with_level_beta(self): + beta_testers_result = set(list_with_level(self.course, 'beta')) + self.assertEqual(set(), beta_testers_result) + + beta_testers = set([UserFactory.create(), UserFactory.create()]) + [allow_access(self.course, user, 'beta') for user in beta_testers] + beta_testers_result = set(list_with_level(self.course, 'beta')) + self.assertEqual(beta_testers, beta_testers_result) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestInstructorAccessForumDB(ModuleStoreTestCase): + def setUp(self): + self.course = CourseFactory.create() + + self.moderators = set([UserFactory.create() for _ in xrange(4)]) + self.mod_role = Role.objects.create(course_id=self.course.id, name=FORUM_ROLE_MODERATOR) + [self.mod_role.users.add(user) for user in self.moderators] + + def test_update_forum_role_membership_allow_existing_role(self): + user = UserFactory.create() + update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow') + self.assertIn(user, self.mod_role.users.all()) + + def test_update_forum_role_membership_allow_existing_role_allowed_user(self): + user = UserFactory.create() + update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow') + update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow') + self.assertIn(user, self.mod_role.users.all()) + + @raises(Role.DoesNotExist) + def test_update_forum_role_membership_allow_not_existing_role(self): + user = UserFactory.create() + update_forum_role_membership(self.course.id, user, FORUM_ROLE_COMMUNITY_TA, 'allow') + + def test_update_forum_role_membership_revoke_existing_role(self): + user = iter(self.moderators).next() + update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke') + self.assertNotIn(user, self.mod_role.users.all()) + + def test_update_forum_role_membership_revoke_existing_role_revoked_user(self): + user = iter(self.moderators).next() + update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke') + update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke') + self.assertNotIn(user, self.mod_role.users.all()) + + @raises(Role.DoesNotExist) + def test_update_forum_role_membership_revoke_not_existing_role(self): + user = iter(self.moderators).next() + update_forum_role_membership(self.course.id, user, FORUM_ROLE_COMMUNITY_TA, 'revoke') + + @raises(Role.DoesNotExist) + def test_update_forum_role_membership_bad_role_allow(self): + user = UserFactory.create() + update_forum_role_membership(self.course.id, user, 'robot-definitely-not-a-forum-role', 'allow') + + @raises(Role.DoesNotExist) + def test_update_forum_role_membership_bad_role_revoke(self): + user = UserFactory.create() + update_forum_role_membership(self.course.id, user, 'robot-definitely-not-a-forum-role', 'revoke') + + @raises(ValueError) + def test_update_forum_role_membership_bad_mode(self): + user = iter(self.moderators).next() + update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'robot-not-a-mode') diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py new file mode 100644 index 0000000000..f4c7228b2b --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -0,0 +1,159 @@ +""" +Unit tests for instructor.enrollment methods. +""" + +import json +from django.contrib.auth.models import Group, User +# from courseware.access import _course_staff_group_name +from courseware.models import StudentModule +from django.test import TestCase +from student.tests.factories import UserFactory + +from student.models import CourseEnrollment, CourseEnrollmentAllowed +from instructor.enrollment import (enroll_emails, unenroll_emails, + split_input_list, reset_student_attempts) + + +class TestInstructorEnrollmentDB(TestCase): + '''Test instructor enrollment administration against database effects''' + def setUp(self): + self.course_id = 'robot:/a/fake/c::rse/id' + + def test_split_input_list(self): + strings = [] + lists = [] + strings.append("Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed") + lists.append(['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed']) + + for (s, l) in zip(strings, lists): + self.assertEqual(split_input_list(s), l) + + def test_enroll_emails_userexists_alreadyenrolled(self): + user = UserFactory() + ce = CourseEnrollment(course_id=self.course_id, user=user) + ce.save() + + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=user.email).count(), 1) + + enroll_emails(self.course_id, [user.email]) + + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=user.email).count(), 1) + + def test_enroll_emails_userexists_succeedenrolling(self): + user = UserFactory() + + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=user.email).count(), 0) + + enroll_emails(self.course_id, [user.email]) + + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=user.email).count(), 1) + + def test_enroll_emails_nouser_alreadyallowed(self): + email_without_user = 'test_enroll_emails_nouser_alreadyallowed@test.org' + + self.assertEqual(User.objects.filter(email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 0) + + cea = CourseEnrollmentAllowed(course_id=self.course_id, email=email_without_user, auto_enroll=False) + cea.save() + + enroll_emails(self.course_id, [email_without_user]) + + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 1) + self.assertEqual(CourseEnrollmentAllowed.objects.get(course_id=self.course_id, email=email_without_user).auto_enroll, False) + + def test_enroll_emails_nouser_suceedallow(self): + email_without_user = 'test_enroll_emails_nouser_suceedallow@test.org' + + self.assertEqual(User.objects.filter(email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 0) + + enroll_emails(self.course_id, [email_without_user]) + + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 1) + self.assertEqual(CourseEnrollmentAllowed.objects.get(course_id=self.course_id, email=email_without_user).auto_enroll, False) + + def test_enroll_multiple(self): + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + email_without_user1 = 'test_enroll_emails_nouser_suceedallow_1@test.org' + email_without_user2 = 'test_enroll_emails_nouser_suceedallow_2@test.org' + email_without_user3 = 'test_enroll_emails_nouser_suceedallow_3@test.org' + + def test_db(auto_enroll): + for user in [user1, user2, user3]: + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user=user).count(), 1) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=user.email).count(), 0) + + for email in [email_without_user1, email_without_user2, email_without_user3]: + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email).count(), 1) + self.assertEqual(CourseEnrollmentAllowed.objects.get(course_id=self.course_id, email=email).auto_enroll, auto_enroll) + + enroll_emails(self.course_id, [user1.email, user2.email, user3.email, email_without_user1, email_without_user2, email_without_user3], auto_enroll=True) + test_db(True) + enroll_emails(self.course_id, [user1.email, user2.email, user3.email, email_without_user1, email_without_user2, email_without_user3], auto_enroll=False) + test_db(False) + + def test_unenroll_alreadyallowed(self): + email_without_user = 'test_unenroll_alreadyallowed@test.org' + cea = CourseEnrollmentAllowed(course_id=self.course_id, email=email_without_user, auto_enroll=False) + cea.save() + + unenroll_emails(self.course_id, [email_without_user]) + + self.assertEqual(User.objects.filter(email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 0) + + def test_unenroll_alreadyenrolled(self): + user = UserFactory() + ce = CourseEnrollment(course_id=self.course_id, user=user) + ce.save() + + unenroll_emails(self.course_id, [user.email]) + + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user=user).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=user.email).count(), 0) + + def test_unenroll_notenrolled(self): + user = UserFactory() + + unenroll_emails(self.course_id, [user.email]) + + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user=user).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=user.email).count(), 0) + + def test_unenroll_nosuchuser(self): + email_without_user = 'test_unenroll_nosuchuser@test.org' + + unenroll_emails(self.course_id, [email_without_user]) + + self.assertEqual(User.objects.filter(email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0) + self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 0) + + def test_reset_student_attempts(self): + user = UserFactory() + msk = 'robot/module/state/key' + original_state = json.dumps({'attempts': 32, 'otherstuff': 'alsorobots'}) + module = StudentModule.objects.create(student=user, course_id=self.course_id, module_state_key=msk, state=original_state) + # lambda to reload the module state from the database + module = lambda: StudentModule.objects.get(student=user, course_id=self.course_id, module_state_key=msk) + self.assertEqual(json.loads(module().state)['attempts'], 32) + reset_student_attempts(self.course_id, user, msk) + self.assertEqual(json.loads(module().state)['attempts'], 0) + + def test_delete_student_attempts(self): + user = UserFactory() + msk = 'robot/module/state/key' + original_state = json.dumps({'attempts': 32, 'otherstuff': 'alsorobots'}) + StudentModule.objects.create(student=user, course_id=self.course_id, module_state_key=msk, state=original_state) + self.assertEqual(StudentModule.objects.filter(student=user, course_id=self.course_id, module_state_key=msk).count(), 1) + reset_student_attempts(self.course_id, user, msk, delete_module=True) + self.assertEqual(StudentModule.objects.filter(student=user, course_id=self.course_id, module_state_key=msk).count(), 0) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 6866126463..f82de025f2 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -172,7 +172,7 @@ def enrolled_students_profiles(request, course_id, csv=False): """ course = get_course_with_access(request.user, course_id, 'staff', depth=None) - available_features = analytics.basic.AVAILABLE_STUDENT_FEATURES + analytics.basic.AVAILABLE_PROFILE_FEATURES + available_features = analytics.basic.AVAILABLE_FEATURES query_features = ['username', 'name', 'email', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals'] @@ -226,7 +226,7 @@ def profile_distribution(request, course_id): try: feature_results[feature] = analytics.distributions.profile_distribution(course_id, feature) except Exception as e: - feature_results[feature] = {'error': "can not find distribution for '%s'" % feature} + feature_results[feature] = {'error': "Error finding distribution for distribution for '{}'.".format(feature)} raise e response_payload = {