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