Commit 306ac482 by dcadams

Email on enroll/un-enroll actions

Optionally email students on enroll/un-enroll actions
by instructor from enrollment tab in LMS.
parent 2cd18dfa
......@@ -152,3 +152,5 @@ Common: Updated CodeJail.
Common: Allow setting of authentication session cookie name.
LMS: Option to email students when enroll/un-enroll them.
......@@ -44,7 +44,7 @@ class GroupFactory(sf.GroupFactory):
@world.absorb
class CourseEnrollmentAllowedFactory(sf.CourseEnrollmentAllowed):
class CourseEnrollmentAllowedFactory(sf.CourseEnrollmentAllowedFactory):
"""
Users allowed to enroll in the course outside of the usual window
"""
......
'''
"""
Unit tests for enrollment methods in views.py
'''
"""
from django.test.utils import override_settings
from django.contrib.auth.models import Group, User
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from courseware.access import _course_staff_group_name
from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE, LoginEnrollmentTestCase
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from instructor.views import get_and_clean_student_list
from instructor.views import get_and_clean_student_list, send_mail_to_student
from django.core import mail
USER_COUNT = 4
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestInstructorEnrollsStudent(LoginEnrollmentTestCase):
'''
Check Enrollment/Unenrollment with/without auto-enrollment on activation
'''
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Check Enrollment/Unenrollment with/without auto-enrollment on activation and with/without email notification
"""
def setUp(self):
self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
self.toy = modulestore().get_course("edX/toy/2012_Fall")
#Create instructor and student accounts
self.instructor = 'instructor1@test.com'
self.student1 = 'student1@test.com'
self.student2 = 'student2@test.com'
self.password = 'foo'
self.create_account('it1', self.instructor, self.password)
self.create_account('st1', self.student1, self.password)
self.create_account('st2', self.student2, self.password)
self.activate_user(self.instructor)
self.activate_user(self.student1)
self.activate_user(self.student2)
instructor = AdminFactory.create()
self.client.login(username=instructor.username, password='test')
def make_instructor(course):
group_name = _course_staff_group_name(course.location)
g = Group.objects.create(name=group_name)
g.user_set.add(get_user(self.instructor))
self.course = CourseFactory.create()
make_instructor(self.toy)
self.users = [
UserFactory.create(username="student%d" % i, email="student%d@test.com" % i)
for i in xrange(USER_COUNT)
]
#Enroll Students
self.logout()
self.login(self.student1, self.password)
self.enroll(self.toy)
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
self.logout()
self.login(self.student2, self.password)
self.enroll(self.toy)
# Empty the test outbox
mail.outbox = []
#Enroll Instructor
self.logout()
self.login(self.instructor, self.password)
self.enroll(self.toy)
def test_unenrollment_email_off(self):
"""
Do un-enrollment email off test
"""
def test_unenrollment(self):
'''
Do un-enrollment test
'''
course = self.course
course = self.toy
#Run the Un-enroll students command
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student1@test.com, student2@test.com'})
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student0@test.com student1@test.com'})
#Check the page output
#Check the page output
self.assertContains(response, '<td>student0@test.com</td>')
self.assertContains(response, '<td>student1@test.com</td>')
self.assertContains(response, '<td>student2@test.com</td>')
self.assertContains(response, '<td>un-enrolled</td>')
#Check the enrollment table
user = User.objects.get(email='student1@test.com')
user = User.objects.get(email='student0@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
user = User.objects.get(email='student2@test.com')
user = User.objects.get(email='student1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
def test_enrollment_new_student_autoenroll_on(self):
'''
Do auto-enroll on test
'''
#Check the outbox
self.assertEqual(len(mail.outbox), 0)
def test_enrollment_new_student_autoenroll_on_email_off(self):
"""
Do auto-enroll on, email off test
"""
course = self.course
#Run the Enroll students command
course = self.toy
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'test1_1@student.com, test1_2@student.com', 'auto_enroll': 'on'})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student1_1@test.com, student1_2@test.com', 'auto_enroll': 'on'})
#Check the page output
self.assertContains(response, '<td>test1_1@student.com</td>')
self.assertContains(response, '<td>test1_2@student.com</td>')
self.assertContains(response, '<td>student1_1@test.com</td>')
self.assertContains(response, '<td>student1_2@test.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on</td>')
#Check the outbox
self.assertEqual(len(mail.outbox), 0)
#Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='test1_1@student.com', course_id=course.id)
cea = CourseEnrollmentAllowed.objects.filter(email='student1_1@test.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='test1_2@student.com', course_id=course.id)
cea = CourseEnrollmentAllowed.objects.filter(email='student1_2@test.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
#Check there is no enrollment db entry other than for the setup instructor and students
#Check there is no enrollment db entry other than for the other students
ce = CourseEnrollment.objects.filter(course_id=course.id)
self.assertEqual(3, len(ce))
self.assertEqual(4, len(ce))
#Create and activate student accounts with same email
self.student1 = 'test1_1@student.com'
self.student1 = 'student1_1@test.com'
self.password = 'bar'
self.create_account('s1_1', self.student1, self.password)
self.activate_user(self.student1)
self.student2 = 'test1_2@student.com'
self.student2 = 'student1_2@test.com'
self.create_account('s1_2', self.student2, self.password)
self.activate_user(self.student2)
#Check students are enrolled
user = User.objects.get(email='test1_1@student.com')
user = User.objects.get(email='student1_1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(1, len(ce))
user = User.objects.get(email='test1_2@student.com')
user = User.objects.get(email='student1_2@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(1, len(ce))
def test_enrollmemt_new_student_autoenroll_off(self):
'''
Do auto-enroll off test
'''
def test_repeat_enroll(self):
"""
Try to enroll an already enrolled student
"""
course = self.course
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student0@test.com', 'auto_enroll': 'on'})
self.assertContains(response, '<td>student0@test.com</td>')
self.assertContains(response, '<td>already enrolled</td>')
def test_enrollmemt_new_student_autoenroll_off_email_off(self):
"""
Do auto-enroll off, email off test
"""
course = self.course
#Run the Enroll students command
course = self.toy
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'test2_1@student.com, test2_2@student.com'})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student2_1@test.com, student2_2@test.com'})
#Check the page output
self.assertContains(response, '<td>test2_1@student.com</td>')
self.assertContains(response, '<td>test2_2@student.com</td>')
self.assertContains(response, '<td>student2_1@test.com</td>')
self.assertContains(response, '<td>student2_2@test.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment off</td>')
#Check the outbox
self.assertEqual(len(mail.outbox), 0)
#Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='test2_1@student.com', course_id=course.id)
cea = CourseEnrollmentAllowed.objects.filter(email='student2_1@test.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='test2_2@student.com', course_id=course.id)
cea = CourseEnrollmentAllowed.objects.filter(email='student2_2@test.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
#Check there is no enrollment db entry other than for the setup instructor and students
ce = CourseEnrollment.objects.filter(course_id=course.id)
self.assertEqual(3, len(ce))
self.assertEqual(4, len(ce))
#Create and activate student accounts with same email
self.student = 'test2_1@student.com'
self.student = 'student2_1@test.com'
self.password = 'bar'
self.create_account('s2_1', self.student, self.password)
self.activate_user(self.student)
self.student = 'test2_2@student.com'
self.student = 'student2_2@test.com'
self.create_account('s2_2', self.student, self.password)
self.activate_user(self.student)
#Check students are not enrolled
user = User.objects.get(email='test2_1@student.com')
user = User.objects.get(email='student2_1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
user = User.objects.get(email='test2_2@student.com')
user = User.objects.get(email='student2_2@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
def test_get_and_clean_student_list(self):
'''
"""
Clean user input test
'''
"""
string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com "
string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com \n mno@test.com "
cleaned_string, cleaned_string_lc = get_and_clean_student_list(string)
self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com'])
self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com', 'mno@test.com'])
def test_enrollment_email_on(self):
"""
Do email on enroll test
"""
course = self.course
#Create activated, but not enrolled, user
UserFactory.create(username="student3_0", email="student3_0@test.com")
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student3_0@test.com, student3_1@test.com, student3_2@test.com', 'auto_enroll': 'on', 'email_students': 'on'})
#Check the page output
self.assertContains(response, '<td>student3_0@test.com</td>')
self.assertContains(response, '<td>student3_1@test.com</td>')
self.assertContains(response, '<td>student3_2@test.com</td>')
self.assertContains(response, '<td>added, email sent</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on, email sent</td>')
#Check the outbox
self.assertEqual(len(mail.outbox), 3)
self.assertEqual(mail.outbox[0].subject, 'You have been enrolled in MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[1].subject, 'You have been invited to register for MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[1].body, "Dear student,\n\nYou have been invited to join MITx/999/Robot_Super_Course at edx.org by a member of the course staff.\n\n" +
"To finish your registration, please visit https://edx.org/register and fill out the registration form.\n" +
"Once you have registered and activated your account, you will see MITx/999/Robot_Super_Course listed on your dashboard.\n\n" +
"----\nThis email was automatically sent from edx.org to student3_1@test.com")
def test_unenrollment_email_on(self):
"""
Do email on unenroll test
"""
course = self.course
#Create invited, but not registered, user
cea = CourseEnrollmentAllowed(email='student4_0@test.com', course_id=course.id)
cea.save()
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student4_0@test.com, student2@test.com, student3@test.com', 'email_students': 'on'})
#Check the page output
self.assertContains(response, '<td>student2@test.com</td>')
self.assertContains(response, '<td>student3@test.com</td>')
self.assertContains(response, '<td>un-enrolled, email sent</td>')
#Check the outbox
self.assertEqual(len(mail.outbox), 3)
self.assertEqual(mail.outbox[0].subject, 'You have been un-enrolled from MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[0].body, "Dear Student,\n\nYou have been un-enrolled from course MITx/999/Robot_Super_Course by a member of the course staff. " +
"Please disregard the invitation previously sent.\n\n" +
"----\nThis email was automatically sent from edx.org to student4_0@test.com")
self.assertEqual(mail.outbox[1].subject, 'You have been un-enrolled from MITx/999/Robot_Super_Course')
def test_send_mail_to_student(self):
"""
Do invalid mail template test
"""
d = {'message': 'message_type_that_doesn\'t_exist'}
send_mail_ret = send_mail_to_student('student0@test.com', d)
self.assertFalse(send_mail_ret)
......@@ -20,6 +20,8 @@ from django.http import HttpResponse
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from django.core.urlresolvers import reverse
from django.core.mail import send_mail
import xmodule.graders as xmgraders
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
......@@ -45,6 +47,7 @@ from mitxmako.shortcuts import render_to_response
from psychometrics import psychoanalyze
from student.models import CourseEnrollment, CourseEnrollmentAllowed
import track.views
from mitxmako.shortcuts import render_to_string
log = logging.getLogger(__name__)
......@@ -634,13 +637,15 @@ def instructor_dashboard(request, course_id):
students = request.POST.get('multiple_students', '')
auto_enroll = bool(request.POST.get('auto_enroll'))
ret = _do_enroll_students(course, course_id, students, auto_enroll=auto_enroll)
email_students = bool(request.POST.get('email_students'))
ret = _do_enroll_students(course, course_id, students, auto_enroll=auto_enroll, email_students=email_students)
datatable = ret['datatable']
elif action == 'Unenroll multiple students':
students = request.POST.get('multiple_students', '')
ret = _do_unenroll_students(course_id, students)
email_students = bool(request.POST.get('email_students'))
ret = _do_unenroll_students(course_id, students, email_students=email_students)
datatable = ret['datatable']
elif action == 'List sections available in remote gradebook':
......@@ -1068,9 +1073,17 @@ def grade_summary(request, course_id):
#-----------------------------------------------------------------------------
# enrollment
def _do_enroll_students(course, course_id, students, overload=False, auto_enroll=False):
"""Do the actual work of enrolling multiple students, presented as a string
of emails separated by commas or returns"""
def _do_enroll_students(course, course_id, students, overload=False, auto_enroll=False, email_students=False):
"""
Do the actual work of enrolling multiple students, presented as a string
of emails separated by commas or returns
`course` is course object
`course_id` id of course (a `str`)
`students` string of student emails separated by commas or returns (a `str`)
`overload` un-enrolls all existing students (a `boolean`)
`auto_enroll` is user input preference (a `boolean`)
`email_students` is user input preference (a `boolean`)
"""
new_students, new_students_lc = get_and_clean_student_list(students)
status = dict([x, 'unprocessed'] for x in new_students)
......@@ -1088,12 +1101,22 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll
status[cea.email] = 'removed from pending enrollment list'
ceaset.delete()
if email_students:
registration_url = 'https://' + settings.SITE_NAME + reverse('student.views.register_user')
#Composition of email
d = {'site_name': settings.SITE_NAME,
'registration_url': registration_url,
'course_id': course_id,
'auto_enroll': auto_enroll,
'course_url': registration_url + '/courses/' + course_id,
}
for student in new_students:
try:
user = User.objects.get(email=student)
except User.DoesNotExist:
#User not signed up yet, put in pending enrollment allowed table
#Student not signed up yet, put in pending enrollment allowed table
cea = CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_id)
#If enrollmentallowed already exists, update auto_enroll flag to however it was set in UI
......@@ -1104,18 +1127,42 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll
status[student] = 'user does not exist, enrollment already allowed, pending with auto enrollment ' \
+ ('on' if auto_enroll else 'off')
continue
#EnrollmentAllowed doesn't exist so create it
cea = CourseEnrollmentAllowed(email=student, course_id=course_id, auto_enroll=auto_enroll)
cea.save()
status[student] = 'user does not exist, enrollment allowed, pending with auto enrollment ' + ('on' if auto_enroll else 'off')
status[student] = 'user does not exist, enrollment allowed, pending with auto enrollment ' \
+ ('on' if auto_enroll else 'off')
if email_students:
#User is allowed to enroll but has not signed up yet
d['email_address'] = student
d['message'] = 'allowed_enroll'
send_mail_ret = send_mail_to_student(student, d)
status[student] += (', email sent' if send_mail_ret else '')
continue
#Student has already registered
if CourseEnrollment.objects.filter(user=user, course_id=course_id):
status[student] = 'already enrolled'
continue
try:
#Not enrolled yet
ce = CourseEnrollment(user=user, course_id=course_id)
ce.save()
status[student] = 'added'
if email_students:
#User enrolled for first time, populate dict with user specific info
d['email_address'] = student
d['first_name'] = user.first_name
d['last_name'] = user.last_name
d['message'] = 'enrolled_enroll'
send_mail_ret = send_mail_to_student(student, d)
status[student] += (', email sent' if send_mail_ret else '')
except:
status[student] = 'rejected'
......@@ -1133,13 +1180,23 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll
#Unenrollment
def _do_unenroll_students(course_id, students):
"""Do the actual work of un-enrolling multiple students, presented as a string
of emails separated by commas or returns"""
def _do_unenroll_students(course_id, students, email_students=False):
"""
Do the actual work of un-enrolling multiple students, presented as a string
of emails separated by commas or returns
`course_id` is id of course (a `str`)
`students` is string of student emails separated by commas or returns (a `str`)
`email_students` is user input preference (a `boolean`)
"""
old_students, _ = get_and_clean_student_list(students)
status = dict([x, 'unprocessed'] for x in old_students)
if email_students:
#Composition of email
d = {'site_name': settings.SITE_NAME,
'course_id': course_id}
for student in old_students:
isok = False
......@@ -1153,6 +1210,14 @@ def _do_unenroll_students(course_id, students):
try:
user = User.objects.get(email=student)
except User.DoesNotExist:
if isok and email_students:
#User was allowed to join but had not signed up yet
d['email_address'] = student
d['message'] = 'allowed_unenroll'
send_mail_ret = send_mail_to_student(student, d)
status[student] += (', email sent' if send_mail_ret else '')
continue
ce = CourseEnrollment.objects.filter(user=user, course_id=course_id)
......@@ -1161,6 +1226,15 @@ def _do_unenroll_students(course_id, students):
try:
ce[0].delete()
status[student] = "un-enrolled"
if email_students:
#User was enrolled
d['email_address'] = student
d['first_name'] = user.first_name
d['last_name'] = user.last_name
d['message'] = 'enrolled_unenroll'
send_mail_ret = send_mail_to_student(student, d)
status[student] += (', email sent' if send_mail_ret else '')
except Exception:
if not isok:
status[student] = "Error! Failed to un-enroll"
......@@ -1173,13 +1247,48 @@ def _do_unenroll_students(course_id, students):
return data
def send_mail_to_student(student, param_dict):
"""
Construct the email using templates and then send it.
`student` is the student's email address (a `str`),
`param_dict` is a `dict` with keys [
`site_name`: name given to edX instance (a `str`)
`registration_url`: url for registration (a `str`)
`course_id`: id of course (a `str`)
`auto_enroll`: user input option (a `str`)
`course_url`: url of course (a `str`)
`email_address`: email of student (a `str`)
`first_name`: student first name (a `str`)
`last_name`: student last name (a `str`)
`message`: type of email to send and template to use (a `str`)
]
Returns a boolean indicating whether the email was sent successfully.
"""
EMAIL_TEMPLATE_DICT = {'allowed_enroll': ('emails/enroll_email_allowedsubject.txt', 'emails/enroll_email_allowedmessage.txt'),
'enrolled_enroll': ('emails/enroll_email_enrolledsubject.txt', 'emails/enroll_email_enrolledmessage.txt'),
'allowed_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_allowedmessage.txt'),
'enrolled_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_enrolledmessage.txt')}
subject_template, message_template = EMAIL_TEMPLATE_DICT.get(param_dict['message'], (None, None))
if subject_template is not None and message_template is not None:
subject = render_to_string(subject_template, param_dict)
message = render_to_string(message_template, param_dict)
# Email subject *must not* contain newlines
subject = ''.join(subject.splitlines())
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [student], fail_silently=False)
return True
else:
return False
def get_and_clean_student_list(students):
"""
Separate out individual student email from the comma, or space separated string.
In:
students: string coming from the input text area
Return:
`students` is string of student emails separated by commas or returns (a `str`)
Returns:
students: list of cleaned student emails
students_lc: list of lower case cleaned student emails
"""
......
......@@ -382,6 +382,8 @@ function goto( mode)
<p>Enroll or un-enroll one or many students: enter emails, separated by new lines or commas;</p>
<textarea rows="6" cols="70" name="multiple_students"></textarea>
<p>
<input type="checkbox" name="email_students"> Notify students by email
<p>
<input type="checkbox" name="auto_enroll"> Auto-enroll students when they activate
<input type="submit" name="action" value="Enroll multiple students">
<p>
......
Dear student,
You have been invited to join ${course_id} at ${site_name} by a member of the course staff.
To finish your registration, please visit ${registration_url} and fill out the registration form.
% if auto_enroll:
Once you have registered and activated your account, you will see ${course_id} listed on your dashboard.
% else:
Once you have registered and activated your account, visit ${course_url} to join the course.
% endif
----
This email was automatically sent from ${site_name} to ${email_address}
\ No newline at end of file
You have been invited to register for ${course_id}
\ No newline at end of file
Dear ${first_name} ${last_name}
You have been enrolled in ${course_id} at ${site_name} by a member of the course staff. The course should now appear on your ${site_name} dashboard.
To start accessing course materials, please visit ${course_url}
----
This email was automatically sent from ${site_name} to ${first_name} ${last_name}
\ No newline at end of file
You have been enrolled in ${course_id}
\ No newline at end of file
Dear Student,
You have been un-enrolled from course ${course_id} by a member of the course staff. Please disregard the invitation previously sent.
----
This email was automatically sent from ${site_name} to ${email_address}
\ No newline at end of file
Dear ${first_name} ${last_name}
You have been un-enrolled in ${course_id} at ${site_name} by a member of the course staff. The course will no longer appear on your ${site_name} dashboard.
Your other courses have not been affected.
----
This email was automatically sent from ${site_name} to ${first_name} ${last_name}
\ No newline at end of file
You have been un-enrolled from ${course_id}
\ No newline at end of file
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