From 1035d67a2558883cb0c6e494117f9ab8640dfb1c Mon Sep 17 00:00:00 2001 From: asadiqbal08 Date: Fri, 17 Oct 2014 19:42:20 +0500 Subject: [PATCH] WL-98 fix typo and add more security on API fix some bugs and typos address PR feedback be sure to send emails when accounts already exist PR feedback fix multiple uploads pep8 fixes pep8 fix pylint fixes fix url mapping WL-98 - Complete code coverage - Update code for error and warning messages. - improve code as per some suggestions updated the UI of the auto_enroll feature fixed the errors PR feedback add test add back file filtering add some more error handling of input remove unneeded coffeescript code pylint fixes add pep8 space WL-98 - Updated and added test cases. - Updated membership coffee file for errors display handling. - fixed minor text issues. allow for blank lines and add a test add blank line (pep8) --- lms/djangoapps/instructor/enrollment.py | 4 + lms/djangoapps/instructor/tests/test_api.py | 240 ++++++++++++++++++ lms/djangoapps/instructor/views/api.py | 190 +++++++++++++- lms/djangoapps/instructor/views/api_urls.py | 2 + .../instructor/views/instructor_dashboard.py | 1 + lms/envs/common.py | 5 + .../instructor_dashboard/membership.coffee | 113 ++++++++- lms/static/sass/base/_variables.scss | 1 + .../sass/course/instructor/_instructor_2.scss | 46 ++++ ...count_creation_and_enroll_emailMessage.txt | 14 + .../instructor_dashboard_2/membership.html | 26 ++ 11 files changed, 632 insertions(+), 10 deletions(-) create mode 100644 lms/templates/emails/account_creation_and_enroll_emailMessage.txt diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 7e6ac0f2f1..f0198e92a5 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -331,6 +331,10 @@ def send_mail_to_student(student, param_dict): 'emails/remove_beta_tester_email_subject.txt', 'emails/remove_beta_tester_email_message.txt' ), + 'account_creation_and_enrollment': ( + 'emails/enroll_email_enrolledsubject.txt', + 'emails/account_creation_and_enroll_emailMessage.txt' + ), } subject_template, message_template = email_template_dict.get(message_type, (None, None)) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 94c980fa5a..3c529ec077 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -9,6 +9,7 @@ import requests import datetime import ddt import random +import io from urllib import quote from django.test import TestCase from nose.tools import raises @@ -40,6 +41,7 @@ from courseware.models import StudentModule # modules which are mocked in test cases. import instructor_task.api import instructor.views.api +from instructor.views.api import generate_unique_password from instructor.views.api import _split_input_list, common_exceptions_400 from instructor_task.api_helper import AlreadyRunningError from opaque_keys.edx.locations import SlashSeparatedCourseKey @@ -48,6 +50,8 @@ from shoppingcart.models import ( PaidCourseRegistration, Coupon, Invoice, CourseRegistrationCode ) from course_modes.models import CourseMode +from django.core.files.uploadedfile import SimpleUploadedFile +from student.models import NonExistentCourseError from .test_tools import msk_from_problem_urlname from ..views.tools import get_extended_due @@ -285,6 +289,242 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): ) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +@patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True}) +class TestInstructorAPIBulkAccountCreationAndEnrollment(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test Bulk account creation and enrollment from csv file + """ + def setUp(self): + self.request = RequestFactory().request() + self.course = CourseFactory.create() + self.instructor = InstructorFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password='test') + self.url = reverse('register_and_enroll_students', kwargs={'course_id': self.course.id.to_deprecated_string()}) + + self.not_enrolled_student = UserFactory( + username='NotEnrolledStudent', + email='nonenrolled@test.com', + first_name='NotEnrolled', + last_name='Student' + ) + + @patch('instructor.views.api.log.info') + def test_account_creation_and_enrollment_with_csv(self, info_log): + """ + Happy path test to create a single new user + """ + csv_content = "test_student@example.com,test_student_1,tester1,USA" + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertEquals(len(data['row_errors']), 0) + self.assertEquals(len(data['warnings']), 0) + self.assertEquals(len(data['general_errors']), 0) + + # test the log for email that's send to new created user. + info_log.assert_called_with('email sent to new created user at test_student@example.com') + + @patch('instructor.views.api.log.info') + def test_account_creation_and_enrollment_with_csv_with_blank_lines(self, info_log): + """ + Happy path test to create a single new user + """ + csv_content = "\ntest_student@example.com,test_student_1,tester1,USA\n\n" + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertEquals(len(data['row_errors']), 0) + self.assertEquals(len(data['warnings']), 0) + self.assertEquals(len(data['general_errors']), 0) + + # test the log for email that's send to new created user. + info_log.assert_called_with('email sent to new created user at test_student@example.com') + + @patch('instructor.views.api.log.info') + def test_email_and_username_already_exist(self, info_log): + """ + If the email address and username already exists + and the user is enrolled in the course, do nothing (including no email gets sent out) + """ + csv_content = "test_student@example.com,test_student_1,tester1,USA\n" \ + "test_student@example.com,test_student_1,tester2,US" + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertEquals(len(data['row_errors']), 0) + self.assertEquals(len(data['warnings']), 0) + self.assertEquals(len(data['general_errors']), 0) + + # test the log for email that's send to new created user. + info_log.assert_called_with("user already exists with username '{username}' and email '{email}'".format(username='test_student_1', email='test_student@example.com')) + + def test_bad_file_upload_type(self): + """ + Try uploading some non-CSV file and verify that it is rejected + """ + uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read()) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertNotEquals(len(data['general_errors']), 0) + self.assertEquals(data['general_errors'][0]['response'], 'Could not read uploaded file.') + + def test_insufficient_data(self): + """ + Try uploading a CSV file which does not have the exact four columns of data + """ + csv_content = "test_student@example.com,test_student_1\n" + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertEquals(len(data['row_errors']), 0) + self.assertEquals(len(data['warnings']), 0) + self.assertEquals(len(data['general_errors']), 1) + self.assertEquals(data['general_errors'][0]['response'], 'Data in row #1 must have exactly four columns: email, username, full name, and country') + + def test_invalid_email_in_csv(self): + """ + Test failure case of a poorly formatted email field + """ + csv_content = "test_student.example.com,test_student_1,tester1,USA" + + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + data = json.loads(response.content) + self.assertEqual(response.status_code, 200) + self.assertNotEquals(len(data['row_errors']), 0) + self.assertEquals(len(data['warnings']), 0) + self.assertEquals(len(data['general_errors']), 0) + self.assertEquals(data['row_errors'][0]['response'], 'Invalid email {0}.'.format('test_student.example.com')) + + @patch('instructor.views.api.log.info') + def test_csv_user_exist_and_not_enrolled(self, info_log): + """ + If the email address and username already exists + and the user is not enrolled in the course, enrolled him/her and iterate to next one. + """ + csv_content = "nonenrolled@test.com,NotEnrolledStudent,tester1,USA" + + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + info_log.assert_called_with('user {username} enrolled in the course {course}'.format(username='NotEnrolledStudent', course=self.course.id)) + + def test_user_with_already_existing_email_in_csv(self): + """ + If the email address already exists, but the username is different, + assume it is the correct user and just register the user in the course. + """ + csv_content = "test_student@example.com,test_student_1,tester1,USA\n" \ + "test_student@example.com,test_student_2,tester2,US" + + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + warning_message = 'An account with email {email} exists but the provided username {username} ' \ + 'is different. Enrolling anyway with {email}.'.format(email='test_student@example.com', username='test_student_2') + self.assertNotEquals(len(data['warnings']), 0) + self.assertEquals(data['warnings'][0]['response'], warning_message) + user = User.objects.get(email='test_student@example.com') + self.assertTrue(CourseEnrollment.is_enrolled(user, self.course.id)) + + def test_user_with_already_existing_username_in_csv(self): + """ + If the username already exists (but not the email), + assume it is a different user and fail to create the new account. + """ + csv_content = "test_student1@example.com,test_student_1,tester1,USA\n" \ + "test_student2@example.com,test_student_1,tester2,US" + + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertNotEquals(len(data['row_errors']), 0) + self.assertEquals(data['row_errors'][0]['response'], 'Username {user} already exists.'.format(user='test_student_1')) + + def test_csv_file_not_attached(self): + """ + Test when the user does not attach a file + """ + csv_content = "test_student1@example.com,test_student_1,tester1,USA\n" \ + "test_student2@example.com,test_student_1,tester2,US" + + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + + response = self.client.post(self.url, {'file_not_found': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertNotEquals(len(data['general_errors']), 0) + self.assertEquals(data['general_errors'][0]['response'], 'File is not attached.') + + def test_raising_exception_in_auto_registration_and_enrollment_case(self): + """ + Test that exceptions are handled well + """ + csv_content = "test_student1@example.com,test_student_1,tester1,USA\n" \ + "test_student2@example.com,test_student_1,tester2,US" + + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + with patch('instructor.views.api.create_and_enroll_user') as mock: + mock.side_effect = NonExistentCourseError() + response = self.client.post(self.url, {'students_list': uploaded_file}) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertNotEquals(len(data['row_errors']), 0) + self.assertEquals(data['row_errors'][0]['response'], 'NonExistentCourseError') + + def test_generate_unique_password(self): + """ + generate_unique_password should generate a unique password string that excludes certain characters. + """ + password = generate_unique_password([], 12) + self.assertEquals(len(password), 12) + for letter in password: + self.assertNotIn(letter, 'aAeEiIoOuU1l') + + def test_users_created_and_enrolled_successfully_if_others_fail(self): + + csv_content = "test_student1@example.com,test_student_1,tester1,USA\n" \ + "test_student3@example.com,test_student_1,tester3,CA\n" \ + "test_student2@example.com,test_student_2,tester2,USA" + + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertNotEquals(len(data['row_errors']), 0) + self.assertEquals(data['row_errors'][0]['response'], 'Username {user} already exists.'.format(user='test_student_1')) + self.assertTrue(User.objects.filter(username='test_student_1', email='test_student1@example.com').exists()) + self.assertTrue(User.objects.filter(username='test_student_2', email='test_student2@example.com').exists()) + self.assertFalse(User.objects.filter(email='test_student3@example.com').exists()) + + @patch.object(instructor.views.api, 'generate_random_string', + Mock(side_effect=['first', 'first', 'second'])) + def test_generate_unique_password_no_reuse(self): + """ + generate_unique_password should generate a unique password string that hasn't been generated before. + """ + generated_password = ['first'] + password = generate_unique_password(generated_password, 12) + self.assertNotEquals(password, 'first') + + @patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': False}) + def test_allow_automated_signups_flag_not_set(self): + csv_content = "test_student1@example.com,test_student_1,tester1,USA" + uploaded_file = SimpleUploadedFile("temp.csv", csv_content) + response = self.client.post(self.url, {'students_list': uploaded_file}) + self.assertEquals(response.status_code, 403) + + @ddt.ddt @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAPIEnrollment(ModuleStoreTestCase, LoginEnrollmentTestCase): diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 7b92a13516..3d5bb7a566 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -50,6 +50,7 @@ from instructor_task.models import ReportStore import instructor.enrollment as enrollment from instructor.enrollment import ( enroll_email, + send_mail_to_student, get_email_params, send_beta_role_email, unenroll_email @@ -83,6 +84,7 @@ from .tools import ( from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys import InvalidKeyError +from student.models import UserProfile, Registration log = logging.getLogger(__name__) @@ -216,6 +218,187 @@ def require_level(level): return decorator +EMAIL_INDEX = 0 +USERNAME_INDEX = 1 +NAME_INDEX = 2 +COUNTRY_INDEX = 3 + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def register_and_enroll_students(request, course_id): # pylint: disable=R0915 + """ + Create new account and Enroll students in this course. + Passing a csv file that contains a list of students. + Order in csv should be the following email = 0; username = 1; name = 2; country = 3. + Requires staff access. + + -If the email address and username already exists and the user is enrolled in the course, + do nothing (including no email gets sent out) + + -If the email address already exists, but the username is different, + match on the email address only and continue to enroll the user in the course using the email address + as the matching criteria. Note the change of username as a warning message (but not a failure). Send a standard enrollment email + which is the same as the existing manual enrollment + + -If the username already exists (but not the email), assume it is a different user and fail to create the new account. + The failure will be messaged in a response in the browser. + """ + + if not microsite.get_value('ALLOW_AUTOMATED_SIGNUPS', settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False)): + return HttpResponseForbidden() + + course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + warnings = [] + row_errors = [] + general_errors = [] + + if 'students_list' in request.FILES: + students = [] + + try: + upload_file = request.FILES.get('students_list') + students = [row for row in csv.reader(upload_file.read().splitlines())] + except Exception: # pylint: disable=W0703 + general_errors.append({ + 'username': '', 'email': '', 'response': _('Could not read uploaded file.') + }) + finally: + upload_file.close() + + generated_passwords = [] + course = get_course_by_id(course_id) + row_num = 0 + for student in students: + row_num = row_num + 1 + + # verify that we have exactly four columns in every row but allow for blank lines + if len(student) != 4: + if len(student) > 0: + general_errors.append({ + 'username': '', + 'email': '', + 'response': _('Data in row #{row_num} must have exactly four columns: email, username, full name, and country').format(row_num=row_num) + }) + continue + + # Iterate each student in the uploaded csv file. + email = student[EMAIL_INDEX] + username = student[USERNAME_INDEX] + name = student[NAME_INDEX] + country = student[COUNTRY_INDEX][:2] + + email_params = get_email_params(course, True, secure=request.is_secure()) + try: + validate_email(email) # Raises ValidationError if invalid + except ValidationError: + row_errors.append({ + 'username': username, 'email': email, 'response': _('Invalid email {email_address}.').format(email_address=email)}) + else: + if User.objects.filter(email=email).exists(): + # Email address already exists. assume it is the correct user + # and just register the user in the course and send an enrollment email. + user = User.objects.get(email=email) + + # see if it is an exact match with email and username + # if it's not an exact match then just display a warning message, but continue onwards + if not User.objects.filter(email=email, username=username).exists(): + warning_message = _( + 'An account with email {email} exists but the provided username {username} ' + 'is different. Enrolling anyway with {email}.' + ).format(email=email, username=username) + + warnings.append({ + 'username': username, 'email': email, 'response': warning_message}) + log.warning('email {email} already exist'.format(email=email)) + else: + log.info("user already exists with username '{username}' and email '{email}'".format(email=email, username=username)) + + # make sure user is enrolled in course + if not CourseEnrollment.is_enrolled(user, course_id): + CourseEnrollment.enroll(user, course_id) + log.info('user {username} enrolled in the course {course}'.format(username=username, course=course.id)) + enroll_email(course_id=course_id, student_email=email, auto_enroll=True, email_students=True, email_params=email_params) + else: + # This email does not yet exist, so we need to create a new account + # If username already exists in the database, then create_and_enroll_user + # will raise an IntegrityError exception. + password = generate_unique_password(generated_passwords) + + try: + create_and_enroll_user(email, username, name, country, password, course_id) + except IntegrityError: + row_errors.append({ + 'username': username, 'email': email, 'response': _('Username {user} already exists.').format(user=username)}) + except Exception as ex: + log.exception(type(ex).__name__) + row_errors.append({ + 'username': username, 'email': email, 'response': _(type(ex).__name__)}) + else: + # It's a new user, an email will be sent to each newly created user. + email_params['message'] = 'account_creation_and_enrollment' + email_params['email_address'] = email + email_params['password'] = password + email_params['platform_name'] = microsite.get_value('platform_name', settings.PLATFORM_NAME) + send_mail_to_student(email, email_params) + log.info('email sent to new created user at {email}'.format(email=email)) + + else: + general_errors.append({ + 'username': '', 'email': '', 'response': _('File is not attached.') + }) + + results = { + 'row_errors': row_errors, + 'general_errors': general_errors, + 'warnings': warnings + } + return JsonResponse(results) + + +def generate_random_string(length): + """ + Create a string of random characters of specified length + """ + chars = [ + char for char in string.ascii_uppercase + string.digits + string.ascii_lowercase + if char not in 'aAeEiIoOuU1l' + ] + + return string.join((random.choice(chars) for __ in range(length)), '') + + +def generate_unique_password(generated_passwords, password_length=12): + """ + generate a unique password for each student. + """ + + password = generate_random_string(password_length) + while password in generated_passwords: + password = generate_random_string(password_length) + + generated_passwords.append(password) + + return password + + +def create_and_enroll_user(email, username, name, country, password, course_id): + """ Creates a user and enroll him/her in the course""" + + user = User.objects.create_user(username, email, password) + reg = Registration() + reg.register(user) + + profile = UserProfile(user=user) + profile.name = name + profile.country = country + profile.save() + + # try to enroll the user in this course + CourseEnrollment.enroll(user, course_id) + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') @@ -852,13 +1035,8 @@ def random_code_generator(): generate a random alphanumeric code of length defined in REGISTRATION_CODE_LENGTH settings """ - chars = '' - for char in string.ascii_uppercase + string.digits + string.ascii_lowercase: - # removing vowel words and specific characters - chars += char.strip('aAeEiIoOuU1l') - code_length = getattr(settings, 'REGISTRATION_CODE_LENGTH', 8) - return string.join((random.choice(chars) for _ in range(code_length)), '') + return generate_random_string(code_length) @ensure_csrf_cookie diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 4968f1b104..d7abd9269d 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -7,6 +7,8 @@ from django.conf.urls import patterns, url urlpatterns = patterns('', # nopep8 url(r'^students_update_enrollment$', 'instructor.views.api.students_update_enrollment', name="students_update_enrollment"), + url(r'^register_and_enroll_students$', + 'instructor.views.api.register_and_enroll_students', name="register_and_enroll_students"), url(r'^list_course_role_members$', 'instructor.views.api.list_course_role_members', name="list_course_role_members"), url(r'^modify_access$', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 12e01a32c3..cdcf2b4e31 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -250,6 +250,7 @@ def _section_membership(course, access): 'access': access, 'enroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_key.to_deprecated_string()}), 'unenroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_key.to_deprecated_string()}), + 'upload_student_csv_button_url': reverse('register_and_enroll_students', kwargs={'course_id': course_key.to_deprecated_string()}), 'modify_beta_testers_button_url': reverse('bulk_beta_modify_access', kwargs={'course_id': course_key.to_deprecated_string()}), 'list_course_role_members_url': reverse('list_course_role_members', kwargs={'course_id': course_key.to_deprecated_string()}), 'modify_access_url': reverse('modify_access', kwargs={'course_id': course_key.to_deprecated_string()}), diff --git a/lms/envs/common.py b/lms/envs/common.py index c57ff34565..5569e6223f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -287,6 +287,11 @@ FEATURES = { # Enable the new dashboard, account, and profile pages 'ENABLE_NEW_DASHBOARD': False, + + # Show a section in the membership tab of the instructor dashboard + # to allow an upload of a CSV file that contains a list of new accounts to create + # and register for course. + 'ALLOW_AUTOMATED_SIGNUPS': False, } # Ignore static asset files on import which match this pattern diff --git a/lms/static/coffee/src/instructor_dashboard/membership.coffee b/lms/static/coffee/src/instructor_dashboard/membership.coffee index 744c2b225e..132ae7e1d0 100644 --- a/lms/static/coffee/src/instructor_dashboard/membership.coffee +++ b/lms/static/coffee/src/instructor_dashboard/membership.coffee @@ -141,7 +141,7 @@ class AuthListWidget extends MemberListWidget url: @list_endpoint data: rolename: @rolename success: (data) => cb? null, data[@rolename] - error: std_ajax_err => + error: std_ajax_err => `// Translators: A rolename appears this sentence. A rolename is something like "staff" or "beta tester".` cb? gettext("Error fetching list for role") + " '#{@rolename}'" @@ -174,6 +174,108 @@ class AuthListWidget extends MemberListWidget else @reload_list() +class AutoEnrollmentViaCsv + constructor: (@$container) -> + # Wrapper for the AutoEnrollmentViaCsv subsection. + # This object handles buttons, success and failure reporting, + # and server communication. + @$student_enrollment_form = @$container.find("form#student-auto-enroll-form") + @$enrollment_signup_button = @$container.find("[name='enrollment_signup_button']") + @$students_list_file = @$container.find("input[name='students_list']") + @$csrf_token = @$container.find("input[name='csrfmiddlewaretoken']") + @$results = @$container.find("div.results") + @$browse_button = @$container.find("#browseBtn") + @$browse_file = @$container.find("#browseFile") + + @processing = false + + @$browse_button.on "change", (event) => + if event.currentTarget.files.length == 1 + @$browse_file.val(event.currentTarget.value.substring(event.currentTarget.value.lastIndexOf("\\") + 1)) + + # attach click handler for @$enrollment_signup_button + @$enrollment_signup_button.click => + @$student_enrollment_form.submit (event) => + if @processing + return false + + @processing = true + + event.preventDefault() + data = new FormData(event.currentTarget) + $.ajax + dataType: 'json' + type: 'POST' + url: event.currentTarget.action + data: data + processData: false + contentType: false + success: (data) => + @processing = false + @display_response data + + return false + + display_response: (data_from_server) -> + @$results.empty() + errors = [] + warnings = [] + + result_from_server_is_success = true + + if data_from_server.general_errors.length + result_from_server_is_success = false + for general_error in data_from_server.general_errors + general_error['is_general_error'] = true + errors.push general_error + + if data_from_server.row_errors.length + result_from_server_is_success = false + for error in data_from_server.row_errors + error['is_general_error'] = false + errors.push error + + if data_from_server.warnings.length + result_from_server_is_success = false + for warning in data_from_server.warnings + warning['is_general_error'] = false + warnings.push warning + + render_response = (label, type, student_results) => + if type is 'success' + task_res_section = $ '
', class: 'message message-confirmation' + message_title = $ '

', class: 'message-title', text: label + task_res_section.append message_title + @$results.append task_res_section + return + + if type is 'error' + task_res_section = $ '
', class: 'message message-error' + if type is 'warning' + task_res_section = $ '
', class: 'message message-warning' + + message_title = $ '

', class: 'message-title', text: label + task_res_section. append message_title + messages_copy = $ '
', class: 'message-copy' + task_res_section. append messages_copy + messages_summary = $ '
    ', class: 'list-summary summary-items' + messages_copy.append messages_summary + + for student_result in student_results + if student_result.is_general_error + response_message = student_result.response + else + response_message = student_result.username + ' ('+ student_result.email + '): ' + ' (' + student_result.response + ')' + messages_summary.append $ '
  • ', class: 'summary-item', text: response_message + + @$results.append task_res_section + + if errors.length + render_response gettext("The following errors were generated:"), 'error', errors + if warnings.length + render_response gettext("The following warnings were generated:"), 'warning', warnings + if result_from_server_is_success + render_response gettext("All accounts were created successfully."), 'success', [] class BetaTesterBulkAddition constructor: (@$container) -> @@ -189,7 +291,7 @@ class BetaTesterBulkAddition @$btn_beta_testers.click (event) => emailStudents = @$checkbox_emailstudents.is(':checked') autoEnroll = @$checkbox_autoenroll.is(':checked') - send_data = + send_data = action: $(event.target).data('action') # 'add' or 'remove' identifiers: @$identifier_input.val() email_students: emailStudents @@ -580,7 +682,10 @@ class Membership # isolate # initialize BatchEnrollment subsection plantTimeout 0, => new BatchEnrollment @$section.find '.batch-enrollment' - + + # isolate # initialize AutoEnrollmentViaCsv subsection + plantTimeout 0, => new AutoEnrollmentViaCsv @$section.find '.auto_enroll_csv' + # initialize BetaTesterBulkAddition subsection plantTimeout 0, => new BetaTesterBulkAddition @$section.find '.batch-beta-testers' @@ -626,4 +731,4 @@ class Membership _.defaults window, InstructorDashboard: {} _.defaults window.InstructorDashboard, sections: {} _.defaults window.InstructorDashboard.sections, - Membership: Membership + Membership: Membership \ No newline at end of file diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 91cb01dfdf..4a149a134b 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -143,6 +143,7 @@ $light-gray: #ddd; // used by descriptor css $lightGrey: #edf1f5; $darkGrey: #8891a1; +$lightGrey1: #ccc; $blue-d1: shade($blue,20%); $blue-d2: shade($blue,40%); $blue-d4: shade($blue,80%); diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 8aebd745be..92965a18aa 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -684,6 +684,52 @@ section.instructor-dashboard-content-2 { } } } + // Auto Enroll Csv Section + .auto_enroll_csv { + .results { + + } + .enrollment_signup_button { + margin-right: ($baseline/4); + } + // Custom File upload + .customBrowseBtn { + margin: ($baseline/2) 0; + display: inline-block; + .file-browse { + position:relative; + overflow:hidden; + display: inline; + margin-left: -5px; + span.browse{ + @include button(simple, $blue); + padding: 6px ($baseline/2); + font-size: 12px; + border-radius: 0 3px 3px 0; + margin-right: ($baseline); + } + input.file_field { + position:absolute; + top:0; + right:0; + margin:0; + padding:0; + cursor:pointer; + opacity:0; + filter:alpha(opacity=0); + } + } + & > span, & input[disabled]{ + vertical-align: middle; + } + input[disabled] { + cursor: not-allowed; + border: 1px solid $lightGrey1; + border-radius: 4px 0 0 4px; + padding: 6px 6px 5px; + } + } + } .enroll-option { margin: ($baseline/2) 0; diff --git a/lms/templates/emails/account_creation_and_enroll_emailMessage.txt b/lms/templates/emails/account_creation_and_enroll_emailMessage.txt new file mode 100644 index 0000000000..c9930d4db6 --- /dev/null +++ b/lms/templates/emails/account_creation_and_enroll_emailMessage.txt @@ -0,0 +1,14 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Welcome to {course_name}").format(course_name=course.display_name_with_default)} + +${_("To get started, please visit https://{site_name}. The login information for your account follows.").format(site_name=site_name)} + +${_("email: {email}").format(email=email_address)} +${_("password: {password}").format(password=password)} + +${_("It is recommended that you change your password.")} + +${_("Sincerely yours,")} + +${_("The {course_name} Team").format(course_name=course.display_name_with_default)} diff --git a/lms/templates/instructor/instructor_dashboard_2/membership.html b/lms/templates/instructor/instructor_dashboard_2/membership.html index 6a91820f3e..905ad0fe87 100644 --- a/lms/templates/instructor/instructor_dashboard_2/membership.html +++ b/lms/templates/instructor/instructor_dashboard_2/membership.html @@ -1,5 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> <%page args="section_data"/> +<%! from microsite_configuration import microsite %> + % endif