Commit 61436ea1 by stephensanchez

Merge branch 'release'

parents bfcffd2d 3775efae
...@@ -201,7 +201,7 @@ ...@@ -201,7 +201,7 @@
> .label { > .label {
display: inline-block; display: inline-block;
max-width: 85%; max-width: 84%;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
......
...@@ -164,6 +164,25 @@ class CourseMode(models.Model): ...@@ -164,6 +164,25 @@ class CourseMode(models.Model):
return 0 return 0
@classmethod @classmethod
def has_payment_options(cls, course_id):
"""Determines if there is any mode that has payment options
Check the dict of course modes and see if any of them have a minimum price or
suggested prices. Returns True if any course mode has a payment option.
Args:
course_mode_dict (dict): Dictionary mapping course mode slugs to Modes
Returns:
True if any course mode has a payment option.
"""
for mode in cls.modes_for_course(course_id):
if mode.min_price > 0 or mode.suggested_prices != '':
return True
return False
@classmethod
def min_course_price_for_currency(cls, course_id, currency): def min_course_price_for_currency(cls, course_id, currency):
""" """
Returns the minimum price of the course in the appropriate currency over all the course's Returns the minimum price of the course in the appropriate currency over all the course's
......
...@@ -127,3 +127,22 @@ class CourseModeModelTest(TestCase): ...@@ -127,3 +127,22 @@ class CourseModeModelTest(TestCase):
mode = CourseMode.verified_mode_for_course(self.course_key) mode = CourseMode.verified_mode_for_course(self.course_key)
self.assertEqual(mode.slug, 'professional') self.assertEqual(mode.slug, 'professional')
def test_course_has_payment_options(self):
# Has no payment options.
honor, _ = self.create_mode('honor', 'Honor')
self.assertFalse(CourseMode.has_payment_options(self.course_key))
# Now we do have a payment option.
verified, _ = self.create_mode('verified', 'Verified', min_price=5)
self.assertTrue(CourseMode.has_payment_options(self.course_key))
# Unset verified's minimum price.
verified.min_price = 0
verified.save()
self.assertFalse(CourseMode.has_payment_options(self.course_key))
# Finally, give the honor mode payment options
honor.suggested_prices = '5, 10, 15'
honor.save()
self.assertTrue(CourseMode.has_payment_options(self.course_key))
"""
Transfer Student Management Command
"""
from django.db import transaction
from opaque_keys.edx.keys import CourseKey
from optparse import make_option from optparse import make_option
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import CourseEnrollment from student.models import CourseEnrollment
from shoppingcart.models import CertificateItem from shoppingcart.models import CertificateItem
from opaque_keys.edx.locations import SlashSeparatedCourseKey from track.management.tracked_command import TrackedCommand
class Command(BaseCommand): class TransferStudentError(Exception):
"""Generic Error when handling student transfers."""
pass
class Command(TrackedCommand):
"""Management Command for transferring students from one course to new courses."""
help = """ help = """
This command takes two course ids as input and transfers This command takes two course ids as input and transfers
all students enrolled in one course into the other. This will all students enrolled in one course into the other. This will
remove them from the first class and enroll them in the second remove them from the first class and enroll them in the specified
class in the same mode as the first one. eg. honor, verified, class(es) in the same mode as the first one. eg. honor, verified,
audit. audit.
example: example:
# Transfer students from the old demoX class to a new one. # Transfer students from the old demoX class to a new one.
manage.py ... transfer_students -f edX/Open_DemoX/edx_demo_course -t edX/Open_DemoX/new_demoX manage.py ... transfer_students -f edX/Open_DemoX/edx_demo_course -t edX/Open_DemoX/new_demoX
# Transfer students from old course to new, with original certificate items.
manage.py ... transfer_students -f edX/Open_DemoX/edx_demo_course -t edX/Open_DemoX/new_demoX -c true
# Transfer students from the old demoX class into two new classes.
manage.py ... transfer_students -f edX/Open_DemoX/edx_demo_course
-t edX/Open_DemoX/new_demoX,edX/Open_DemoX/edX_Insider
""" """
option_list = BaseCommand.option_list + ( option_list = TrackedCommand.option_list + (
make_option('-f', '--from', make_option('-f', '--from',
metavar='SOURCE_COURSE', metavar='SOURCE_COURSE',
dest='source_course', dest='source_course',
help='The course to transfer students from.'), help='The course to transfer students from.'),
make_option('-t', '--to', make_option('-t', '--to',
metavar='DEST_COURSE', metavar='DEST_COURSE_LIST',
dest='dest_course', dest='dest_course_list',
help='The new course to enroll the student into.'), help='The new course(es) to enroll the student into.'),
make_option('-c', '--transfer-certificates',
metavar='TRANSFER_CERTIFICATES',
dest='transfer_certificates',
help="If True, try to transfer certificate items to the new course.")
) )
def handle(self, *args, **options): @transaction.commit_manually
source_key = SlashSeparatedCourseKey.from_deprecated_string(options['source_course']) def handle(self, *args, **options): # pylint: disable=unused-argument
dest_key = SlashSeparatedCourseKey.from_deprecated_string(options['dest_course']) source_key = CourseKey.from_string(options.get('source_course', ''))
dest_keys = []
for course_key in options.get('dest_course_list', '').split(','):
dest_keys.append(CourseKey.from_string(course_key))
if not source_key or not dest_keys:
raise TransferStudentError(u"Must have a source course and destination course specified.")
tc_option = options.get('transfer_certificates', '')
transfer_certificates = ('true' == tc_option.lower()) if tc_option else False
if transfer_certificates and len(dest_keys) != 1:
raise TransferStudentError(u"Cannot transfer certificate items from one course to many.")
source_students = User.objects.filter( source_students = User.objects.filter(
courseenrollment__course_id=source_key courseenrollment__course_id=source_key
) )
for user in source_students: for user in source_students:
if CourseEnrollment.is_enrolled(user, dest_key): with transaction.commit_on_success():
# Un Enroll from source course but don't mess print("Moving {}.".format(user.username))
# with the enrollment in the destination course. # Find the old enrollment.
CourseEnrollment.unenroll(user, source_key) enrollment = CourseEnrollment.objects.get(
print("Unenrolled {} from {}".format(user.username, source_key.to_deprecated_string())) user=user,
msg = "Skipping {}, already enrolled in destination course {}" course_id=source_key
print(msg.format(user.username, dest_key.to_deprecated_string())) )
continue
# Move the Student between the classes.
print("Moving {}.".format(user.username)) mode = enrollment.mode
# Find the old enrollment. old_is_active = enrollment.is_active
enrollment = CourseEnrollment.objects.get( CourseEnrollment.unenroll(user, source_key, emit_unenrollment_event=False)
user=user, print(u"Unenrolled {} from {}".format(user.username, unicode(source_key)))
course_id=source_key
for dest_key in dest_keys:
if CourseEnrollment.is_enrolled(user, dest_key):
# Un Enroll from source course but don't mess
# with the enrollment in the destination course.
msg = u"Skipping {}, already enrolled in destination course {}"
print(msg.format(user.username, unicode(dest_key)))
else:
new_enrollment = CourseEnrollment.enroll(user, dest_key, mode=mode)
# Un-enroll from the new course if the user had un-enrolled
# form the old course.
if not old_is_active:
new_enrollment.update_enrollment(is_active=False, emit_unenrollment_event=False)
if transfer_certificates:
self._transfer_certificate_item(source_key, enrollment, user, dest_keys, new_enrollment)
@staticmethod
def _transfer_certificate_item(source_key, enrollment, user, dest_keys, new_enrollment):
""" Transfer the certificate item from one course to another.
Do not use this generally, since certificate items are directly associated with a particular purchase.
This should only be used when a single course to a new location. This cannot be used when transferring
from one course to many.
Args:
source_key (str): The course key string representation for the original course.
enrollment (CourseEnrollment): The original enrollment to move the certificate item from.
user (User): The user to transfer the item for.
dest_keys (list): A list of course key strings to transfer the item to.
new_enrollment (CourseEnrollment): The new enrollment to associate the certificate item with.
Returns:
None
"""
try:
certificate_item = CertificateItem.objects.get(
course_id=source_key,
course_enrollment=enrollment
) )
except CertificateItem.DoesNotExist:
print(u"No certificate for {}".format(user))
return
# Move the Student between the classes. certificate_item.course_id = dest_keys[0]
mode = enrollment.mode certificate_item.course_enrollment = new_enrollment
old_is_active = enrollment.is_active
CourseEnrollment.unenroll(user, source_key)
new_enrollment = CourseEnrollment.enroll(user, dest_key, mode=mode)
# Unenroll from the new coures if the user had unenrolled
# form the old course.
if not old_is_active:
new_enrollment.update_enrollment(is_active=False)
if mode == 'verified':
try:
certificate_item = CertificateItem.objects.get(
course_id=source_key,
course_enrollment=enrollment
)
except CertificateItem.DoesNotExist:
print("No certificate for {}".format(user))
continue
certificate_item.course_id = dest_key
certificate_item.course_enrollment = new_enrollment
certificate_item.save()
"""Tests for Student Management Commands."""
"""
Tests the transfer student management command
"""
from django.conf import settings
from opaque_keys.edx import locator
import unittest
import ddt
from student.management.commands import transfer_students
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt
class TestTransferStudents(ModuleStoreTestCase):
"""Tests for transferring students between courses."""
PASSWORD = 'test'
def test_transfer_students(self):
student = UserFactory()
student.set_password(self.PASSWORD) # pylint: disable=E1101
student.save() # pylint: disable=E1101
# Original Course
original_course_location = locator.CourseLocator('Org0', 'Course0', 'Run0')
course = self._create_course(original_course_location)
# Enroll the student in 'verified'
CourseEnrollment.enroll(student, course.id, mode="verified")
# New Course 1
course_location_one = locator.CourseLocator('Org1', 'Course1', 'Run1')
new_course_one = self._create_course(course_location_one)
# New Course 2
course_location_two = locator.CourseLocator('Org2', 'Course2', 'Run2')
new_course_two = self._create_course(course_location_two)
original_key = unicode(course.id)
new_key_one = unicode(new_course_one.id)
new_key_two = unicode(new_course_two.id)
# Run the actual management command
transfer_students.Command().handle(
source_course=original_key, dest_course_list=new_key_one + "," + new_key_two
)
# Confirm the enrollment mode is verified on the new courses, and enrollment is enabled as appropriate.
self.assertEquals(('verified', False), CourseEnrollment.enrollment_mode_for_user(student, course.id))
self.assertEquals(('verified', True), CourseEnrollment.enrollment_mode_for_user(student, new_course_one.id))
self.assertEquals(('verified', True), CourseEnrollment.enrollment_mode_for_user(student, new_course_two.id))
def _create_course(self, course_location):
""" Creates a course """
return CourseFactory.create(
org=course_location.org,
number=course_location.course,
run=course_location.run
)
...@@ -776,7 +776,7 @@ class CourseEnrollment(models.Model): ...@@ -776,7 +776,7 @@ class CourseEnrollment(models.Model):
is_course_full = cls.num_enrolled_in(course.id) >= course.max_student_enrollments_allowed is_course_full = cls.num_enrolled_in(course.id) >= course.max_student_enrollments_allowed
return is_course_full return is_course_full
def update_enrollment(self, mode=None, is_active=None): def update_enrollment(self, mode=None, is_active=None, emit_unenrollment_event=True):
""" """
Updates an enrollment for a user in a class. This includes options Updates an enrollment for a user in a class. This includes options
like changing the mode, toggling is_active True/False, etc. like changing the mode, toggling is_active True/False, etc.
...@@ -784,6 +784,7 @@ class CourseEnrollment(models.Model): ...@@ -784,6 +784,7 @@ class CourseEnrollment(models.Model):
Also emits relevant events for analytics purposes. Also emits relevant events for analytics purposes.
This saves immediately. This saves immediately.
""" """
activation_changed = False activation_changed = False
# if is_active is None, then the call to update_enrollment didn't specify # if is_active is None, then the call to update_enrollment didn't specify
...@@ -813,7 +814,7 @@ class CourseEnrollment(models.Model): ...@@ -813,7 +814,7 @@ class CourseEnrollment(models.Model):
u"mode:{}".format(self.mode)] u"mode:{}".format(self.mode)]
) )
else: elif emit_unenrollment_event:
UNENROLL_DONE.send(sender=None, course_enrollment=self) UNENROLL_DONE.send(sender=None, course_enrollment=self)
self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED) self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED)
...@@ -987,7 +988,7 @@ class CourseEnrollment(models.Model): ...@@ -987,7 +988,7 @@ class CourseEnrollment(models.Model):
raise raise
@classmethod @classmethod
def unenroll(cls, user, course_id): def unenroll(cls, user, course_id, emit_unenrollment_event=True):
""" """
Remove the user from a given course. If the relevant `CourseEnrollment` Remove the user from a given course. If the relevant `CourseEnrollment`
object doesn't exist, we log an error but don't throw an exception. object doesn't exist, we log an error but don't throw an exception.
...@@ -997,10 +998,12 @@ class CourseEnrollment(models.Model): ...@@ -997,10 +998,12 @@ class CourseEnrollment(models.Model):
adding an enrollment for it. adding an enrollment for it.
`course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
`emit_unenrollment_events` can be set to False to suppress events firing.
""" """
try: try:
record = CourseEnrollment.objects.get(user=user, course_id=course_id) record = CourseEnrollment.objects.get(user=user, course_id=course_id)
record.update_enrollment(is_active=False) record.update_enrollment(is_active=False, emit_unenrollment_event=emit_unenrollment_event)
except cls.DoesNotExist: except cls.DoesNotExist:
err_msg = u"Tried to unenroll student {} from {} but they were not enrolled" err_msg = u"Tried to unenroll student {} from {} but they were not enrolled"
......
...@@ -697,7 +697,10 @@ def _allow_donation(course_modes, course_id): ...@@ -697,7 +697,10 @@ def _allow_donation(course_modes, course_id):
True if the course is allowing donations. True if the course is allowing donations.
""" """
return DonationConfiguration.current().enabled and not CourseMode.has_verified_mode(course_modes[course_id]) donations_enabled = DonationConfiguration.current().enabled
is_verified_mode = CourseMode.has_verified_mode(course_modes[course_id])
has_payment_option = CourseMode.has_payment_options(course_id)
return donations_enabled and not is_verified_mode and not has_payment_option
def try_change_enrollment(request): def try_change_enrollment(request):
......
...@@ -331,6 +331,10 @@ def send_mail_to_student(student, param_dict): ...@@ -331,6 +331,10 @@ def send_mail_to_student(student, param_dict):
'emails/remove_beta_tester_email_subject.txt', 'emails/remove_beta_tester_email_subject.txt',
'emails/remove_beta_tester_email_message.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)) subject_template, message_template = email_template_dict.get(message_type, (None, None))
......
...@@ -50,6 +50,7 @@ from instructor_task.models import ReportStore ...@@ -50,6 +50,7 @@ from instructor_task.models import ReportStore
import instructor.enrollment as enrollment import instructor.enrollment as enrollment
from instructor.enrollment import ( from instructor.enrollment import (
enroll_email, enroll_email,
send_mail_to_student,
get_email_params, get_email_params,
send_beta_role_email, send_beta_role_email,
unenroll_email unenroll_email
...@@ -83,6 +84,7 @@ from .tools import ( ...@@ -83,6 +84,7 @@ from .tools import (
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from student.models import UserProfile, Registration
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -216,6 +218,187 @@ def require_level(level): ...@@ -216,6 +218,187 @@ def require_level(level):
return decorator 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 @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff') @require_level('staff')
...@@ -852,13 +1035,8 @@ def random_code_generator(): ...@@ -852,13 +1035,8 @@ def random_code_generator():
generate a random alphanumeric code of length defined in generate a random alphanumeric code of length defined in
REGISTRATION_CODE_LENGTH settings 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) 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 @ensure_csrf_cookie
......
...@@ -7,6 +7,8 @@ from django.conf.urls import patterns, url ...@@ -7,6 +7,8 @@ from django.conf.urls import patterns, url
urlpatterns = patterns('', # nopep8 urlpatterns = patterns('', # nopep8
url(r'^students_update_enrollment$', url(r'^students_update_enrollment$',
'instructor.views.api.students_update_enrollment', name="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$', url(r'^list_course_role_members$',
'instructor.views.api.list_course_role_members', name="list_course_role_members"), 'instructor.views.api.list_course_role_members', name="list_course_role_members"),
url(r'^modify_access$', url(r'^modify_access$',
......
...@@ -250,6 +250,7 @@ def _section_membership(course, access): ...@@ -250,6 +250,7 @@ def _section_membership(course, access):
'access': access, 'access': access,
'enroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_key.to_deprecated_string()}), '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()}), '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()}), '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()}), '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()}), 'modify_access_url': reverse('modify_access', kwargs={'course_id': course_key.to_deprecated_string()}),
......
...@@ -223,14 +223,21 @@ class Order(models.Model): ...@@ -223,14 +223,21 @@ class Order(models.Model):
if is_order_type_business: if is_order_type_business:
for cart_item in cart_items: for cart_item in cart_items:
if hasattr(cart_item, 'paidcourseregistration'): if hasattr(cart_item, 'paidcourseregistration'):
CourseRegCodeItem.add_to_order(self, cart_item.paidcourseregistration.course_id, cart_item.qty) course_reg_code_item = CourseRegCodeItem.add_to_order(self, cart_item.paidcourseregistration.course_id, cart_item.qty)
# update the discounted prices if coupon redemption applied
course_reg_code_item.list_price = cart_item.list_price
course_reg_code_item.unit_cost = cart_item.unit_cost
course_reg_code_item.save()
items_to_delete.append(cart_item) items_to_delete.append(cart_item)
else: else:
for cart_item in cart_items: for cart_item in cart_items:
if hasattr(cart_item, 'courseregcodeitem'): if hasattr(cart_item, 'courseregcodeitem'):
PaidCourseRegistration.add_to_order(self, cart_item.courseregcodeitem.course_id) paid_course_registration = PaidCourseRegistration.add_to_order(self, cart_item.courseregcodeitem.course_id)
# update the discounted prices if coupon redemption applied
paid_course_registration.list_price = cart_item.list_price
paid_course_registration.unit_cost = cart_item.unit_cost
paid_course_registration.save()
items_to_delete.append(cart_item) items_to_delete.append(cart_item)
# CourseRegCodeItem.add_to_order
for item in items_to_delete: for item in items_to_delete:
item.delete() item.delete()
...@@ -254,7 +261,7 @@ class Order(models.Model): ...@@ -254,7 +261,7 @@ class Order(models.Model):
for registration_code in registration_codes: for registration_code in registration_codes:
redemption_url = reverse('register_code_redemption', args=[registration_code.code]) redemption_url = reverse('register_code_redemption', args=[registration_code.code])
url = '{base_url}{redemption_url}'.format(base_url=site_name, redemption_url=redemption_url) url = '{base_url}{redemption_url}'.format(base_url=site_name, redemption_url=redemption_url)
csv_writer.writerow([course.display_name, registration_code.code, url]) csv_writer.writerow([unicode(course.display_name).encode("utf-8"), registration_code.code, url])
return csv_file, course_info return csv_file, course_info
...@@ -312,7 +319,12 @@ class Order(models.Model): ...@@ -312,7 +319,12 @@ class Order(models.Model):
from_email=from_address, from_email=from_address,
to=[recipient[1]] to=[recipient[1]]
) )
email.content_subtype = "html"
# only the business order is HTML formatted
# the single seat is simple text
if is_order_type_business:
email.content_subtype = "html"
if csv_file: if csv_file:
email.attach(u'RegistrationCodesRedemptionUrls.csv', csv_file.getvalue(), 'text/csv') email.attach(u'RegistrationCodesRedemptionUrls.csv', csv_file.getvalue(), 'text/csv')
email.send() email.send()
......
...@@ -287,6 +287,11 @@ FEATURES = { ...@@ -287,6 +287,11 @@ FEATURES = {
# Enable the new dashboard, account, and profile pages # Enable the new dashboard, account, and profile pages
'ENABLE_NEW_DASHBOARD': False, '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 # Ignore static asset files on import which match this pattern
......
...@@ -196,3 +196,11 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract ...@@ -196,3 +196,11 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract
) )
services['fs'] = xblock.reference.plugins.FSService() services['fs'] = xblock.reference.plugins.FSService()
super(LmsModuleSystem, self).__init__(**kwargs) super(LmsModuleSystem, self).__init__(**kwargs)
# backward compatibility fix for callers not knowing this is a ModuleSystem v DescriptorSystem
@property
def resources_fs(self):
"""
Return what would be the resources_fs on a DescriptorSystem
"""
return getattr(self, 'filestore', None)
...@@ -141,7 +141,7 @@ class AuthListWidget extends MemberListWidget ...@@ -141,7 +141,7 @@ class AuthListWidget extends MemberListWidget
url: @list_endpoint url: @list_endpoint
data: rolename: @rolename data: rolename: @rolename
success: (data) => cb? null, data[@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".` `// Translators: A rolename appears this sentence. A rolename is something like "staff" or "beta tester".`
cb? gettext("Error fetching list for role") + " '#{@rolename}'" cb? gettext("Error fetching list for role") + " '#{@rolename}'"
...@@ -174,6 +174,108 @@ class AuthListWidget extends MemberListWidget ...@@ -174,6 +174,108 @@ class AuthListWidget extends MemberListWidget
else else
@reload_list() @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 class BetaTesterBulkAddition
constructor: (@$container) -> constructor: (@$container) ->
...@@ -189,7 +291,7 @@ class BetaTesterBulkAddition ...@@ -189,7 +291,7 @@ class BetaTesterBulkAddition
@$btn_beta_testers.click (event) => @$btn_beta_testers.click (event) =>
emailStudents = @$checkbox_emailstudents.is(':checked') emailStudents = @$checkbox_emailstudents.is(':checked')
autoEnroll = @$checkbox_autoenroll.is(':checked') autoEnroll = @$checkbox_autoenroll.is(':checked')
send_data = send_data =
action: $(event.target).data('action') # 'add' or 'remove' action: $(event.target).data('action') # 'add' or 'remove'
identifiers: @$identifier_input.val() identifiers: @$identifier_input.val()
email_students: emailStudents email_students: emailStudents
...@@ -580,7 +682,10 @@ class Membership ...@@ -580,7 +682,10 @@ class Membership
# isolate # initialize BatchEnrollment subsection # isolate # initialize BatchEnrollment subsection
plantTimeout 0, => new BatchEnrollment @$section.find '.batch-enrollment' plantTimeout 0, => new BatchEnrollment @$section.find '.batch-enrollment'
# isolate # initialize AutoEnrollmentViaCsv subsection
plantTimeout 0, => new AutoEnrollmentViaCsv @$section.find '.auto_enroll_csv'
# initialize BetaTesterBulkAddition subsection # initialize BetaTesterBulkAddition subsection
plantTimeout 0, => new BetaTesterBulkAddition @$section.find '.batch-beta-testers' plantTimeout 0, => new BetaTesterBulkAddition @$section.find '.batch-beta-testers'
...@@ -626,4 +731,4 @@ class Membership ...@@ -626,4 +731,4 @@ class Membership
_.defaults window, InstructorDashboard: {} _.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {} _.defaults window.InstructorDashboard, sections: {}
_.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; ...@@ -143,6 +143,7 @@ $light-gray: #ddd;
// used by descriptor css // used by descriptor css
$lightGrey: #edf1f5; $lightGrey: #edf1f5;
$darkGrey: #8891a1; $darkGrey: #8891a1;
$lightGrey1: #ccc;
$blue-d1: shade($blue,20%); $blue-d1: shade($blue,20%);
$blue-d2: shade($blue,40%); $blue-d2: shade($blue,40%);
$blue-d4: shade($blue,80%); $blue-d4: shade($blue,80%);
......
...@@ -684,6 +684,52 @@ section.instructor-dashboard-content-2 { ...@@ -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 { .enroll-option {
margin: ($baseline/2) 0; margin: ($baseline/2) 0;
......
...@@ -285,7 +285,6 @@ $edx-footer-bg-color: rgb(252,252,252); ...@@ -285,7 +285,6 @@ $edx-footer-bg-color: rgb(252,252,252);
} }
.edx-footer-new { .edx-footer-new {
width: 100%;
background: $edx-footer-bg-color; background: $edx-footer-bg-color;
// NOTE: resetting older footer styles - can be removed once not needed // NOTE: resetting older footer styles - can be removed once not needed
......
...@@ -479,6 +479,9 @@ ...@@ -479,6 +479,9 @@
pointer-events: none; pointer-events: none;
} }
} }
.no-width {
width: 0px !important;
}
.col-3{ .col-3{
width: 100px; width: 100px;
float: right; float: right;
......
<%! 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 _ %> <%! from django.utils.translation import ugettext as _ %>
<%page args="section_data"/> <%page args="section_data"/>
<%! from microsite_configuration import microsite %>
<script type="text/template" id="member-list-widget-template"> <script type="text/template" id="member-list-widget-template">
<div class="member-list-widget"> <div class="member-list-widget">
...@@ -68,6 +69,30 @@ ...@@ -68,6 +69,30 @@
<div class="request-response-error"></div> <div class="request-response-error"></div>
</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" /> <hr class="divider" />
%if section_data['access']['instructor']: %if section_data['access']['instructor']:
...@@ -245,5 +270,6 @@ ...@@ -245,5 +270,6 @@
} }
}); });
</script> </script>
</%block> </%block>
% endif % endif
...@@ -70,7 +70,7 @@ from courseware.courses import course_image_url, get_course_about_section, get_c ...@@ -70,7 +70,7 @@ from courseware.courses import course_image_url, get_course_about_section, get_c
<td>${registration_code.code}</td> <td>${registration_code.code}</td>
<% redemption_url = reverse('register_code_redemption', args = [registration_code.code] ) %> <% redemption_url = reverse('register_code_redemption', args = [registration_code.code] ) %>
<% enrollment_url = '{base_url}{redemption_url}'.format(base_url=site_name, redemption_url=redemption_url) %> <% enrollment_url = '{redemption_url}'.format(redemption_url=redemption_url) %>
<td><a href="${redemption_url}">${enrollment_url}</a></td> <td><a href="${redemption_url}">${enrollment_url}</a></td>
</tr> </tr>
% endfor % endfor
......
...@@ -58,7 +58,7 @@ from django.utils.translation import ugettext as _ ...@@ -58,7 +58,7 @@ from django.utils.translation import ugettext as _
</div> </div>
</div> </div>
<div class="col-3"> <div class="col-3 no-width">
<a href="#" class="btn-remove" data-item-id="${item.id}"><i class="icon-remove-sign"></i></a> <a href="#" class="btn-remove" data-item-id="${item.id}"><i class="icon-remove-sign"></i></a>
</div> </div>
</div> </div>
......
...@@ -5,13 +5,14 @@ from django.utils.translation import ugettext as _ ...@@ -5,13 +5,14 @@ from django.utils.translation import ugettext as _
<%namespace name='static' file='/static_content.html'/> <%namespace name='static' file='/static_content.html'/>
<%block name="pagetitle">${_("Shopping cart")}</%block> <%block name="pagetitle">${_("Shopping cart")}</%block>
<%! from django.conf import settings %>
<%! from microsite_configuration import microsite %>
<%block name="bodyextra"> <%block name="bodyextra">
<div class="container"> <div class="container">
<section class="wrapper confirm-enrollment shopping-cart"> <section class="wrapper confirm-enrollment shopping-cart">
<h1> ${_("{site_name} - Shopping Cart").format(site_name=site_name)}</h1> <h1> ${_("{platform_name} - Shopping Cart").format(platform_name=microsite.get_value('platform_name', settings.PLATFORM_NAME))}</h1>
% if shoppingcart_items: % if shoppingcart_items:
<ul class="steps"> <ul class="steps">
<li <%block name="review_highlight"/>>${_('Review')}</li> <li <%block name="review_highlight"/>>${_('Review')}</li>
......
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