Commit 1035d67a by asadiqbal08 Committed by Chris Dodge

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)
parent 655381b4
......@@ -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))
......
......@@ -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
......
......@@ -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$',
......
......@@ -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()}),
......
......@@ -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
......
......@@ -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 = $ '<div/>', class: 'message message-confirmation'
message_title = $ '<h3/>', class: 'message-title', text: label
task_res_section.append message_title
@$results.append task_res_section
return
if type is 'error'
task_res_section = $ '<div/>', class: 'message message-error'
if type is 'warning'
task_res_section = $ '<div/>', class: 'message message-warning'
message_title = $ '<h3/>', class: 'message-title', text: label
task_res_section. append message_title
messages_copy = $ '<div/>', class: 'message-copy'
task_res_section. append messages_copy
messages_summary = $ '<ul/>', 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 $ '<li/>', 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
......@@ -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%);
......
......@@ -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;
......
<%! 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)}
<%! from django.utils.translation import ugettext as _ %>
<%page args="section_data"/>
<%! from microsite_configuration import microsite %>
<script type="text/template" id="member-list-widget-template">
<div class="member-list-widget">
......@@ -68,6 +69,30 @@
<div class="request-response-error"></div>
</div>
%if microsite.get_value('ALLOW_AUTOMATED_SIGNUPS', settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False)):
<hr class="divider" />
<div class="auto_enroll auto_enroll_csv">
<h2> ${_("Register/Enroll Students")} </h2>
<p>
${_("To register and enroll a list of users in this course, choose a CSV file that contains the following columns in this exact order: email, username, name, and country. Please include one student per row and do not include any headers, footers, or blank lines.")}
</p>
<form id="student-auto-enroll-form" method="post" action="${ section_data['upload_student_csv_button_url'] }" enctype="multipart/form-data">
<div class="customBrowseBtn">
<input disabled="disabled" id="browseFile" placeholder="choose file" />
<div class="file-browse btn btn-primary">
<span class="browse"> Browse </span>
<input class="file_field" id="browseBtn" name="students_list" type="file" accept=".csv"/>
</div>
</div>
<button type="submit" name="enrollment_signup_button">${_("Upload CSV")}</button>
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
</form>
<div class="results"></div>
</div>
%endif
<hr class="divider" />
%if section_data['access']['instructor']:
......@@ -245,5 +270,6 @@
}
});
</script>
</%block>
% endif
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment