Commit 94ac02e8 by Miles Steele

refactor enrollment action reporting

parent 096f7dcd
...@@ -4,137 +4,101 @@ Enrollment operations for use by instructor APIs. ...@@ -4,137 +4,101 @@ Enrollment operations for use by instructor APIs.
Does not include any access control, be sure to check access before calling. Does not include any access control, be sure to check access before calling.
""" """
import re
import json import json
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.models import CourseEnrollment, CourseEnrollmentAllowed
from courseware.models import StudentModule from courseware.models import StudentModule
def enroll_emails(course_id, student_emails, auto_enroll=False): class EmailEnrollmentState(object):
""" Store the complete enrollment state of an email in a class """
def __init__(self, course_id, email):
exists_user = User.objects.filter(email=email).exists()
exists_ce = CourseEnrollment.objects.filter(course_id=course_id, user__email=email).exists()
ceas = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=email).all()
exists_allowed = len(ceas) > 0
state_auto_enroll = exists_allowed and ceas[0].auto_enroll
self.user = exists_user
self.enrollment = exists_ce
self.allowed = exists_allowed
self.auto_enroll = bool(state_auto_enroll)
def __repr__(self):
return "{}(user={}, enrollment={}, allowed={}, auto_enroll={})".format(
self.__class__.__name__,
self.user,
self.enrollment,
self.allowed,
self.auto_enroll,
)
def to_dict(self):
""" """
Enroll multiple students by email. example: {
'user': False,
'enrollment': False,
'allowed': True,
'auto_enroll': True,
}
"""
return {
'user': self.user,
'enrollment': self.enrollment,
'allowed': self.allowed,
'auto_enroll': self.auto_enroll,
}
students is a list of student emails e.g. ["foo@bar.com", "bar@foo.com]
each of whom possibly does not exist in db.
status contains the relevant prior state and action performed on the user. def enroll_email(course_id, student_email, auto_enroll=False):
ce stands for CourseEnrollment
cea stands for CourseEnrollmentAllowed
! stands for the object not existing prior to the action
return a mapping from status to emails.
""" """
Enroll a student by email.
auto_string = 'willautoenroll' if auto_enroll else 'allowed' `student_email` is student's emails e.g. "foo@bar.com"
`auto_enroll` determines what is put in CourseEnrollmentAllowed.auto_enroll
if auto_enroll is set, then when the email registers, they will be
enrolled in the course automatically.
status_map = { returns two EmailEnrollmentState's
'user/ce/alreadyenrolled': [], representing state before and after the action.
'user/!ce/enrolled': [], """
'user/!ce/rejected': [],
'!user/cea/' + auto_string: [],
'!user/!cea/' + auto_string: [],
}
for student_email in student_emails: previous_state = EmailEnrollmentState(course_id, student_email)
# status: user
try:
user = User.objects.get(email=student_email)
# status: user/ce if previous_state.user:
try: user = User.objects.get(email=student_email)
CourseEnrollment.objects.get(user=user, course_id=course_id) CourseEnrollment.objects.get_or_create(course_id=course_id, user=user)
status_map['user/ce/alreadyenrolled'].append(student_email) else:
# status: user/!ce cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=student_email)
except CourseEnrollment.DoesNotExist:
# status: user/!ce/enrolled
try:
cenr = CourseEnrollment(user=user, course_id=course_id)
cenr.save()
status_map['user/!ce/enrolled'].append(student_email)
# status: user/!ce/rejected
except Exception:
status_map['user/!ce/rejected'].append(student_email)
# status: !user
except User.DoesNotExist:
# status: !user/cea
try:
cea = CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email)
cea.auto_enroll = auto_enroll cea.auto_enroll = auto_enroll
cea.save() cea.save()
status_map['!user/cea/' + auto_string].append(student_email)
# status: !user/!cea
except CourseEnrollmentAllowed.DoesNotExist:
cea = CourseEnrollmentAllowed(course_id=course_id, email=student_email, auto_enroll=auto_enroll)
cea.save()
status_map['!user/!cea/' + auto_string].append(student_email)
return status_map
after_state = EmailEnrollmentState(course_id, student_email)
def unenroll_emails(course_id, student_emails): return previous_state, after_state
"""
Unenroll multiple students by email.
`students` is a list of student emails e.g. ["foo@bar.com", "bar@foo.com]
each of whom possibly does not exist in db.
Fail quietly on student emails that do not match any users or allowed enrollments.
status contains the relevant prior state and action performed on the user. def unenroll_email(course_id, student_email):
ce stands for CourseEnrollment
cea stands for CourseEnrollmentAllowed
! stands for the object not existing prior to the action
return a mapping from status to emails.
""" """
Unenroll a student by email.
# NOTE these are not mutually exclusive `student_email` is student's emails e.g. "foo@bar.com"
status_map = {
'cea/disallowed': [],
'ce/unenrolled': [],
'ce/rejected': [],
'!ce/notenrolled': [],
}
for student_email in student_emails: returns two EmailEnrollmentState's
# delete CourseEnrollmentAllowed representing state before and after the action.
try:
cea = CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email)
cea.delete()
status_map['cea/disallowed'].append(student_email)
except CourseEnrollmentAllowed.DoesNotExist:
pass
# delete CourseEnrollment
try:
cenr = CourseEnrollment.objects.get(course_id=course_id, user__email=student_email)
try:
cenr.delete()
status_map['ce/unenrolled'].append(student_email)
except Exception:
status_map['ce/rejected'].append(student_email)
except CourseEnrollment.DoesNotExist:
status_map['!ce/notenrolled'].append(student_email)
return status_map
def split_input_list(str_list):
""" """
Separate out individual student email from the comma, or space separated string.
e.g. previous_state = EmailEnrollmentState(course_id, student_email)
in: "Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed"
out: ['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed']
`str_list` is a string coming from an input text area if previous_state.enrollment:
returns a list of separated values CourseEnrollment.objects.get(course_id=course_id, user__email=student_email).delete()
"""
if previous_state.allowed:
CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email).delete()
new_list = re.split(r'[\n\r\s,]', str_list) after_state = EmailEnrollmentState(course_id, student_email)
new_list = [s.strip() for s in new_list]
new_list = [s for s in new_list if s != '']
return new_list return previous_state, after_state
def reset_student_attempts(course_id, student, module_state_key, delete_module=False): def reset_student_attempts(course_id, student, module_state_key, delete_module=False):
......
"""
Unit tests for instructor.enrollment methods.
"""
from django.test import TestCase
from instructor.views.api import _split_input_list
class TestInstructorAPIHelpers(TestCase):
""" Test helpers for instructor.api """
def test_split_input_list(self):
strings = []
lists = []
strings.append("Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed")
lists.append(['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed'])
for (stng, lst) in zip(strings, lists):
self.assertEqual(_split_input_list(stng), lst)
...@@ -3,139 +3,291 @@ Unit tests for instructor.enrollment methods. ...@@ -3,139 +3,291 @@ Unit tests for instructor.enrollment methods.
""" """
import json import json
from abc import ABCMeta
from django.contrib.auth.models import User from django.contrib.auth.models import User
from courseware.models import StudentModule from courseware.models import StudentModule
from django.test import TestCase from django.test import TestCase
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.models import CourseEnrollment, CourseEnrollmentAllowed
from instructor.enrollment import (enroll_emails, unenroll_emails, from instructor.enrollment import (EmailEnrollmentState,
split_input_list, reset_student_attempts) enroll_email, unenroll_email,
reset_student_attempts)
class TestInstructorEnrollmentDB(TestCase): class TestMockEnrollmentState(TestCase):
'''Test instructor enrollment administration against database effects''' """ Test the basis class for enrollment tests. """
def setUp(self): def setUp(self):
self.course_id = 'robot:/a/fake/c::rse/id' self.course_id = 'robot:/a/fake/c::rse/id'
def test_split_input_list(self): def test_mes_create(self):
strings = [] """
lists = [] Test MockEnrollmentState creation of user.
strings.append("Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed") """
lists.append(['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed']) mes = MockEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False
)
email, user, cenr, cea = mes.create_user(self.course_id)
ees = EmailEnrollmentState(self.course_id, email)
self.assertEqual(mes, ees)
for (stng, lst) in zip(strings, lists):
self.assertEqual(split_input_list(stng), lst)
def test_enroll_emails_userexists_alreadyenrolled(self): class TestEnrollmentChangeBase(TestCase):
user = UserFactory() """
cenr = CourseEnrollment(course_id=self.course_id, user=user) Test instructor enrollment administration against database effects.
cenr.save()
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=user.email).count(), 1)
enroll_emails(self.course_id, [user.email])
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=user.email).count(), 1)
def test_enroll_emails_userexists_succeedenrolling(self):
user = UserFactory()
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=user.email).count(), 0)
enroll_emails(self.course_id, [user.email])
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=user.email).count(), 1)
def test_enroll_emails_nouser_alreadyallowed(self):
email_without_user = 'robot_enroll_emails_nouser_alreadyallowed@test.org'
self.assertEqual(User.objects.filter(email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 0)
cea = CourseEnrollmentAllowed(course_id=self.course_id, email=email_without_user, auto_enroll=False)
cea.save()
enroll_emails(self.course_id, [email_without_user])
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 1)
self.assertEqual(CourseEnrollmentAllowed.objects.get(course_id=self.course_id, email=email_without_user).auto_enroll, False)
def test_enroll_emails_nouser_suceedallow(self):
email_without_user = 'robot_enroll_emails_nouser_suceedallow@test.org'
self.assertEqual(User.objects.filter(email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 0)
enroll_emails(self.course_id, [email_without_user])
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 1)
self.assertEqual(CourseEnrollmentAllowed.objects.get(course_id=self.course_id, email=email_without_user).auto_enroll, False)
def test_enroll_multiple(self):
user1 = UserFactory()
user2 = UserFactory()
user3 = UserFactory()
email_without_user1 = 'robot_enroll_emails_nouser_suceedallow_1@test.org'
email_without_user2 = 'robot_enroll_emails_nouser_suceedallow_2@test.org'
email_without_user3 = 'robot_enroll_emails_nouser_suceedallow_3@test.org'
def test_db(auto_enroll): Test methods in derived classes follow a strict format.
for user in [user1, user2, user3]: `action` is a function which is run
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user=user).count(), 1) the test will pass if `action` mutates state from `before_ideal` to `after_ideal`
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=user.email).count(), 0) """
for email in [email_without_user1, email_without_user2, email_without_user3]: __metaclass__ = ABCMeta
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email).count(), 0)
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email).count(), 1)
self.assertEqual(CourseEnrollmentAllowed.objects.get(course_id=self.course_id, email=email).auto_enroll, auto_enroll)
enroll_emails(self.course_id, [user1.email, user2.email, user3.email, email_without_user1, email_without_user2, email_without_user3], auto_enroll=True) def setUp(self):
test_db(True) self.course_id = 'robot:/a/fake/c::rse/id'
enroll_emails(self.course_id, [user1.email, user2.email, user3.email, email_without_user1, email_without_user2, email_without_user3], auto_enroll=False)
test_db(False)
def test_unenroll_alreadyallowed(self):
email_without_user = 'robot_unenroll_alreadyallowed@test.org'
cea = CourseEnrollmentAllowed(course_id=self.course_id, email=email_without_user, auto_enroll=False)
cea.save()
unenroll_emails(self.course_id, [email_without_user])
self.assertEqual(User.objects.filter(email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 0)
def test_unenroll_alreadyenrolled(self):
user = UserFactory()
cenr = CourseEnrollment(course_id=self.course_id, user=user)
cenr.save()
unenroll_emails(self.course_id, [user.email])
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user=user).count(), 0) def _run_state_change_test(self, before_ideal, after_ideal, action):
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=user.email).count(), 0) """
Runs a state change test.
`before_ideal` and `after_ideal` are MockEnrollmentState's
`action` is a function which will be run in the middle.
`action` should transition the world from before_ideal to after_ideal
`action` will be supplied the following arguments (None-able arguments)
`email` is an email string
`user` is a User
`cenr` is a CourseEnrollment
`cea` is a CourseEnrollmentAllowed
"""
# initialize & check before
print "checking initialization..."
email, user, cenr, cea = before_ideal.create_user(self.course_id)
before = EmailEnrollmentState(self.course_id, email)
self.assertEqual(before, before_ideal)
# do action
print "running action..."
action(email, user, cenr, cea)
# check after
print "checking effects..."
after = EmailEnrollmentState(self.course_id, email)
self.assertEqual(after, after_ideal)
class TestInstructorEnrollDB(TestEnrollmentChangeBase):
""" Test instructor.enrollment.enroll_email """
def test_enroll(self):
before_ideal = MockEnrollmentState(
user=True,
enrollment=False,
allowed=False,
auto_enroll=False
)
after_ideal = MockEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False
)
def action(email, user, cenr, cea):
enroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_again(self):
before_ideal = MockEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False,
)
after_ideal = MockEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False,
)
def action(email, user, cenr, cea):
enroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_nouser(self):
before_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False,
)
after_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=False,
)
def action(email, user, cenr, cea):
enroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_nouser_again(self):
before_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=False
)
after_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=False,
)
def action(email, user, cenr, cea):
enroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_nouser_autoenroll(self):
before_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False,
)
after_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=True,
)
def action(email, user, cenr, cea):
enroll_email(self.course_id, email, auto_enroll=True)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_nouser_change_autoenroll(self):
before_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=True,
)
after_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=False,
)
def action(email, user, cenr, cea):
enroll_email(self.course_id, email, auto_enroll=False)
return self._run_state_change_test(before_ideal, after_ideal, action)
class TestInstructorUnenrollDB(TestEnrollmentChangeBase):
""" Test instructor.enrollment.unenroll_email """
def test_unenroll(self):
before_ideal = MockEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False
)
after_ideal = MockEnrollmentState(
user=True,
enrollment=False,
allowed=False,
auto_enroll=False
)
def action(email, user, cenr, cea):
unenroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_unenroll_notenrolled(self): def test_unenroll_notenrolled(self):
user = UserFactory() before_ideal = MockEnrollmentState(
user=True,
unenroll_emails(self.course_id, [user.email]) enrollment=False,
allowed=False,
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user=user).count(), 0) auto_enroll=False
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=user.email).count(), 0) )
def test_unenroll_nosuchuser(self): after_ideal = MockEnrollmentState(
email_without_user = 'robot_unenroll_nosuchuser@test.org' user=True,
enrollment=False,
unenroll_emails(self.course_id, [email_without_user]) allowed=False,
auto_enroll=False
self.assertEqual(User.objects.filter(email=email_without_user).count(), 0) )
self.assertEqual(CourseEnrollment.objects.filter(course_id=self.course_id, user__email=email_without_user).count(), 0)
self.assertEqual(CourseEnrollmentAllowed.objects.filter(course_id=self.course_id, email=email_without_user).count(), 0) def action(email, user, cenr, cea):
unenroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_unenroll_disallow(self):
before_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=True
)
after_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False
)
def action(email, user, cenr, cea):
unenroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_unenroll_norecord(self):
before_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False
)
after_ideal = MockEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False
)
def action(email, user, cenr, cea):
unenroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
class TestInstructorEnrollmentStudentModule(TestCase):
""" Test student module manipulations. """
def setUp(self):
self.course_id = 'robot:/a/fake/c::rse/id'
def test_reset_student_attempts(self): def test_reset_student_attempts(self):
user = UserFactory() user = UserFactory()
...@@ -156,3 +308,55 @@ class TestInstructorEnrollmentDB(TestCase): ...@@ -156,3 +308,55 @@ class TestInstructorEnrollmentDB(TestCase):
self.assertEqual(StudentModule.objects.filter(student=user, course_id=self.course_id, module_state_key=msk).count(), 1) self.assertEqual(StudentModule.objects.filter(student=user, course_id=self.course_id, module_state_key=msk).count(), 1)
reset_student_attempts(self.course_id, user, msk, delete_module=True) reset_student_attempts(self.course_id, user, msk, delete_module=True)
self.assertEqual(StudentModule.objects.filter(student=user, course_id=self.course_id, module_state_key=msk).count(), 0) self.assertEqual(StudentModule.objects.filter(student=user, course_id=self.course_id, module_state_key=msk).count(), 0)
class MockEnrollmentState(EmailEnrollmentState):
"""
Settable enrollment state.
Used for tests
"""
def __init__(self, user=False, enrollment=False, allowed=False, auto_enroll=False):
self.user = user
self.enrollment = enrollment
self.allowed = allowed
self.auto_enroll = auto_enroll
def __eq__(self, other):
return self.to_dict() == other.to_dict()
def __neq__(self, other):
return not self == other
def create_user(self, course_id=None):
"""
Utility method to possibly create and possibly enroll a user.
Creates a state matching the MockEnrollmentState properties.
Returns a tuple of (
email,
User, (optionally None)
CourseEnrollment, (optionally None)
CourseEnrollmentAllowed, (optionally None)
)
"""
# if self.user=False, then this will just be used to generate an email.
email = "robot_no_user_exists_with_this_email@edx.org"
if self.user:
user = UserFactory()
email = user.email
if self.enrollment:
cenr = CourseEnrollment.objects.create(
user=user,
course_id=course_id
)
return (email, user, cenr, None)
else:
return (email, user, None, None)
elif self.allowed:
cea = CourseEnrollmentAllowed.objects.create(
email=email,
course_id=course_id,
auto_enroll=self.auto_enroll,
)
return (email, None, None, cea)
else:
return (email, None, None, None)
...@@ -7,6 +7,7 @@ TODO add tracking ...@@ -7,6 +7,7 @@ TODO add tracking
TODO a lot of these GETs should be PUTs TODO a lot of these GETs should be PUTs
""" """
import re
import json import json
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control from django.views.decorators.cache import cache_control
...@@ -23,7 +24,7 @@ from django_comment_common.models import (Role, ...@@ -23,7 +24,7 @@ from django_comment_common.models import (Role,
from courseware.models import StudentModule from courseware.models import StudentModule
import instructor_task.api import instructor_task.api
import instructor.enrollment as enrollment import instructor.enrollment as enrollment
from instructor.enrollment import split_input_list, enroll_emails, unenroll_emails from instructor.enrollment import enroll_email, unenroll_email
import instructor.access as access import instructor.access as access
import analytics.basic import analytics.basic
import analytics.distributions import analytics.distributions
...@@ -40,9 +41,49 @@ def common_exceptions_400(func): ...@@ -40,9 +41,49 @@ def common_exceptions_400(func):
return wrapped return wrapped
def require_query_params(*args, **kwargs):
"""
Checks for required paremters or renders a 400 error.
`args` is a *list of required GET parameter names.
`kwargs` is a **dict of required GET parameter names
to string explanations of the parameter
"""
required_params = []
required_params += [(arg, None) for arg in args]
required_params += [(key, kwargs[key]) for key in kwargs]
# required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]
def decorator(func):
def wrapped(*args, **kwargs):
request = args[0]
error_response_data = {
'error': 'Missing required query parameter(s)',
'parameters': [],
'info': {},
}
for (param, extra) in required_params:
default = object()
if request.GET.get(param, default) == default:
error_response_data['parameters'] += [param]
error_response_data['info'][param] = extra
if len(error_response_data['parameters']) > 0:
return HttpResponseBadRequest(
json.dumps(error_response_data),
mimetype="application/json",
)
else:
return func(*args, **kwargs)
return wrapped
return decorator
@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)
def students_update_enrollment_email(request, course_id): @require_query_params(action="enroll or unenroll", emails="stringified list of emails")
def students_update_enrollment(request, course_id):
""" """
Enroll or unenroll students by email. Enroll or unenroll students by email.
Requires staff access. Requires staff access.
...@@ -57,15 +98,36 @@ def students_update_enrollment_email(request, course_id): ...@@ -57,15 +98,36 @@ def students_update_enrollment_email(request, course_id):
) )
action = request.GET.get('action') action = request.GET.get('action')
emails = split_input_list(request.GET.get('emails')) emails = _split_input_list(request.GET.get('emails'))
auto_enroll = request.GET.get('auto_enroll') in ['true', 'True', True] auto_enroll = request.GET.get('auto_enroll') in ['true', 'True', True]
def format_result(func, email):
""" Act on a single email and format response or errors. """
try:
before, after = func()
return {
'email': email,
'before': before.to_dict(),
'after': after.to_dict(),
}
except Exception:
return {
'email': email,
'error': True,
}
if action == 'enroll': if action == 'enroll':
results = enroll_emails(course_id, emails, auto_enroll=auto_enroll) results = [format_result(
lambda: enroll_email(course_id, email, auto_enroll),
email
) for email in emails]
elif action == 'unenroll': elif action == 'unenroll':
results = unenroll_emails(course_id, emails) results = [format_result(
lambda: unenroll_email(course_id, email),
email
) for email in emails]
else: else:
raise ValueError("unrecognized action '{}'".format(action)) return HttpResponseBadRequest("Unrecognized action '{}'".format(action))
response_payload = { response_payload = {
'action': action, 'action': action,
...@@ -81,6 +143,11 @@ def students_update_enrollment_email(request, course_id): ...@@ -81,6 +143,11 @@ def students_update_enrollment_email(request, 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)
@common_exceptions_400 @common_exceptions_400
@require_query_params(
email="user email",
rolename="'instructor', 'staff', or 'beta'",
mode="'allow' or 'revoke'"
)
def access_allow_revoke(request, course_id): def access_allow_revoke(request, course_id):
""" """
Modify staff/instructor access. Modify staff/instructor access.
...@@ -119,6 +186,7 @@ def access_allow_revoke(request, course_id): ...@@ -119,6 +186,7 @@ def access_allow_revoke(request, 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_query_params(rolename="'instructor', 'staff', or 'beta'")
def list_course_role_members(request, course_id): def list_course_role_members(request, course_id):
""" """
List instructors and staff. List instructors and staff.
...@@ -306,6 +374,7 @@ def get_student_progress_url(request, course_id): ...@@ -306,6 +374,7 @@ def get_student_progress_url(request, 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)
@common_exceptions_400 @common_exceptions_400
@require_query_params(student_email="student email")
def redirect_to_student_progress(request, course_id): def redirect_to_student_progress(request, course_id):
""" """
Redirects to the specified students progress page Redirects to the specified students progress page
...@@ -318,9 +387,6 @@ def redirect_to_student_progress(request, course_id): ...@@ -318,9 +387,6 @@ def redirect_to_student_progress(request, course_id):
) )
student_email = request.GET.get('student_email') student_email = request.GET.get('student_email')
if not student_email:
return HttpResponseBadRequest("Must provide an email.")
user = User.objects.get(email=student_email) user = User.objects.get(email=student_email)
progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id}) progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id})
...@@ -363,7 +429,7 @@ def reset_student_attempts(request, course_id): ...@@ -363,7 +429,7 @@ def reset_student_attempts(request, course_id):
if will_delete_module and all_students: if will_delete_module and all_students:
return HttpResponseBadRequest() return HttpResponseBadRequest()
module_state_key = _module_state_key_from_problem_urlname(course_id, problem_to_reset) module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
response_payload = {} response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset response_payload['problem_to_reset'] = problem_to_reset
...@@ -412,7 +478,7 @@ def rescore_problem(request, course_id): ...@@ -412,7 +478,7 @@ def rescore_problem(request, course_id):
if not (problem_to_reset and (all_students or student_email)): if not (problem_to_reset and (all_students or student_email)):
return HttpResponseBadRequest() return HttpResponseBadRequest()
module_state_key = _module_state_key_from_problem_urlname(course_id, problem_to_reset) module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
response_payload = {} response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset response_payload['problem_to_reset'] = problem_to_reset
...@@ -456,7 +522,7 @@ def list_instructor_tasks(request, course_id): ...@@ -456,7 +522,7 @@ def list_instructor_tasks(request, course_id):
return HttpResponseBadRequest() return HttpResponseBadRequest()
if problem_urlname: if problem_urlname:
module_state_key = _module_state_key_from_problem_urlname(course_id, problem_urlname) module_state_key = _msk_from_problem_urlname(course_id, problem_urlname)
if student_email: if student_email:
student = User.objects.get(email=student_email) student = User.objects.get(email=student_email)
tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student) tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student)
...@@ -540,7 +606,7 @@ def update_forum_role_membership(request, course_id): ...@@ -540,7 +606,7 @@ def update_forum_role_membership(request, course_id):
rolename = request.GET.get('rolename') rolename = request.GET.get('rolename')
mode = request.GET.get('mode') mode = request.GET.get('mode')
if not rolename in [access.FORUM_ROLE_ADMINISTRATOR, access.FORUM_ROLE_MODERATOR, access.FORUM_ROLE_COMMUNITY_TA]: if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
return HttpResponseBadRequest() return HttpResponseBadRequest()
try: try:
...@@ -560,7 +626,30 @@ def update_forum_role_membership(request, course_id): ...@@ -560,7 +626,30 @@ def update_forum_role_membership(request, course_id):
return response return response
def _module_state_key_from_problem_urlname(course_id, urlname): def _split_input_list(str_list):
"""
Separate out individual student email from the comma, or space separated string.
e.g.
in: "Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed"
out: ['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed']
`str_list` is a string coming from an input text area
returns a list of separated values
"""
new_list = re.split(r'[\n\r\s,]', str_list)
new_list = [s.strip() for s in new_list]
new_list = [s for s in new_list if s != '']
return new_list
def _msk_from_problem_urlname(course_id, urlname):
"""
Convert a 'problem urlname' (instructor input name)
to a module state key (db field)
"""
if urlname[-4:] == ".xml": if urlname[-4:] == ".xml":
urlname = urlname[:-4] urlname = urlname[:-4]
......
...@@ -105,8 +105,8 @@ def _section_membership(course_id, access): ...@@ -105,8 +105,8 @@ def _section_membership(course_id, access):
'section_key': 'membership', 'section_key': 'membership',
'section_display_name': 'Membership', 'section_display_name': 'Membership',
'access': access, 'access': access,
'enroll_button_url': reverse('students_update_enrollment_email', kwargs={'course_id': course_id}), 'enroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'unenroll_button_url': reverse('students_update_enrollment_email', kwargs={'course_id': course_id}), 'unenroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'list_course_role_members_url': reverse('list_course_role_members', kwargs={'course_id': course_id}), 'list_course_role_members_url': reverse('list_course_role_members', kwargs={'course_id': course_id}),
'access_allow_revoke_url': reverse('access_allow_revoke', kwargs={'course_id': course_id}), 'access_allow_revoke_url': reverse('access_allow_revoke', kwargs={'course_id': course_id}),
'list_forum_members_url': reverse('list_forum_members', kwargs={'course_id': course_id}), 'list_forum_members_url': reverse('list_forum_members', kwargs={'course_id': course_id}),
...@@ -121,7 +121,7 @@ def _section_student_admin(course_id): ...@@ -121,7 +121,7 @@ def _section_student_admin(course_id):
'section_key': 'student_admin', 'section_key': 'student_admin',
'section_display_name': 'Student Admin', 'section_display_name': 'Student Admin',
'get_student_progress_url': reverse('get_student_progress_url', kwargs={'course_id': course_id}), 'get_student_progress_url': reverse('get_student_progress_url', kwargs={'course_id': course_id}),
'enrollment_url': reverse('students_update_enrollment_email', kwargs={'course_id': course_id}), 'enrollment_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'reset_student_attempts_url': reverse('reset_student_attempts', kwargs={'course_id': course_id}), 'reset_student_attempts_url': reverse('reset_student_attempts', kwargs={'course_id': course_id}),
'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': course_id}), 'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': course_id}),
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}), 'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}),
......
...@@ -60,65 +60,100 @@ class BatchEnrollment ...@@ -60,65 +60,100 @@ class BatchEnrollment
$task_response.empty() $task_response.empty()
$request_response_error.empty() $request_response_error.empty()
response_code_dict = _.extend {}, data_from_server.results # these results arrays contain student_results
# response_code_dict e.g. {'code': ['email1', 'email2'], ...} # only populated arrays will be rendered
message_ordering = [ #
'msg_error_enroll' # students for which there was an error during the action
'msg_error_unenroll' errors = []
'msg_enrolled' # students who are now enrolled in the course
'msg_unenrolled' enrolled = []
'msg_willautoenroll' # students who are now allowed to enroll in the course
'msg_allowed' allowed = []
'msg_disallowed' # students who will be autoenrolled on registration
'msg_already_enrolled' autoenrolled = []
'msg_notenrolled' # students who are now not enrolled in the course
] notenrolled = []
# categorize student results into the above arrays.
for student_results in data_from_server.results
# for a successful action.
# student_results is of the form {
# "email": "jd405@edx.org",
# "before": {
# "enrollment": true,
# "auto_enroll": false,
# "user": true,
# "allowed": false
# }
# "after": {
# "enrollment": true,
# "auto_enroll": false,
# "user": true,
# "allowed": false
# },
# }
#
# for an action error.
# student_results is of the form {
# 'email': email,
# 'error': True,
# }
if student_results.error != undefined
errors.push student_results
else if student_results.after.enrollment
enrolled.push student_results
else if student_results.after.allowed
if student_results.after.auto_enroll
autoenrolled.push student_results
else
allowed.push student_results
else if not student_results.after.enrollment
notenrolled.push student_results
else
console.warn 'student results not reported to user'
console.warn student_results
msg_to_txt = { # render populated result arrays
msg_already_enrolled: "Already enrolled:" render_list = (label, emails) ->
msg_enrolled: "Enrolled:" log emails
msg_error_enroll: "There was an error enrolling these students:"
msg_allowed: "These students will be allowed to enroll once they register:"
msg_willautoenroll: "These students will be enrolled once they register:"
msg_unenrolled: "Unenrolled:"
msg_error_unenroll: "There was an error unenrolling these students:"
msg_disallowed: "These students were removed from those who can enroll once they register:"
msg_notenrolled: "These students were not enrolled:"
}
msg_to_codes = {
msg_already_enrolled: ['user/ce/alreadyenrolled']
msg_enrolled: ['user/!ce/enrolled']
msg_error_enroll: ['user/!ce/rejected']
msg_allowed: ['!user/cea/allowed', '!user/!cea/allowed']
msg_willautoenroll: ['!user/cea/willautoenroll', '!user/!cea/willautoenroll']
msg_unenrolled: ['ce/unenrolled']
msg_error_unenroll: ['ce/rejected']
msg_disallowed: ['cea/disallowed']
msg_notenrolled: ['!ce/notenrolled']
}
for msg_symbol in message_ordering
# $task_response.text JSON.stringify(data)
msg_txt = msg_to_txt[msg_symbol]
task_res_section = $ '<div/>', class: 'request-res-section' task_res_section = $ '<div/>', class: 'request-res-section'
task_res_section.append $ '<h3/>', text: msg_txt task_res_section.append $ '<h3/>', text: label
email_list = $ '<ul/>' email_list = $ '<ul/>'
task_res_section.append email_list task_res_section.append email_list
will_attach = false
for code in msg_to_codes[msg_symbol]
emails = response_code_dict[code]
if emails and emails.length
for email in emails for email in emails
email_list.append $ '<li/>', text: email email_list.append $ '<li/>', text: email
will_attach = true
if will_attach
$task_response.append task_res_section $task_response.append task_res_section
if errors.length
errors_label = do ->
if data_from_server.action is 'enroll'
"There was an error enrolling:"
else if data_from_server.action is 'unenroll'
"There was an error unenrolling:"
else else
task_res_section.remove() console.warn "unknown action from server '#{data_from_server.action}'"
"There was an error processing:"
for student_results in errors
console.log 'error with': student_results.email
if enrolled.length
render_list "Students Enrolled:", (sr.email for sr in enrolled)
if allowed.length
render_list "These students will be allowed to enroll once they register:",
(sr.email for sr in allowed)
if autoenrolled.length
render_list "These students will be enrolled once they register:",
(sr.email for sr in autoenrolled)
if notenrolled.length
render_list "These students are now not enrolled:",
(sr.email for sr in notenrolled)
# manages a list of instructors or staff and the control of their access. # manages a list of instructors or staff and the control of their access.
......
...@@ -128,7 +128,6 @@ class StudentAdmin ...@@ -128,7 +128,6 @@ class StudentAdmin
send_data = send_data =
action: 'unenroll' action: 'unenroll'
emails: @$field_student_select.val() emails: @$field_student_select.val()
auto_enroll: false
$.ajax $.ajax
dataType: 'json' dataType: 'json'
......
...@@ -346,8 +346,8 @@ if settings.COURSEWARE_ENABLED and settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR ...@@ -346,8 +346,8 @@ if settings.COURSEWARE_ENABLED and settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR
'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard_2"), 'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard_2"),
# api endpoints for instructor # api endpoints for instructor
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/students_update_enrollment_email$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/students_update_enrollment$',
'instructor.views.api.students_update_enrollment_email', name="students_update_enrollment_email"), 'instructor.views.api.students_update_enrollment', name="students_update_enrollment"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/list_course_role_members$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/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'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/access_allow_revoke$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/access_allow_revoke$',
......
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