Commit 9dc68b03 by Don Mitchell

Improve auth handling of Locators

Ensure user admin screen gets the union of all possibly matching group names.
Smarter default group naming.
STUD-1003
parent 45d373c2
"""
Studio authorization functions primarily for course creators, instructors, and staff
"""
#======================================================================================================================= #=======================================================================================================================
# #
# This code is somewhat duplicative of access.py in the LMS. We will unify the code as a separate story # This code is somewhat duplicative of access.py in the LMS. We will unify the code as a separate story
...@@ -11,7 +14,8 @@ from django.conf import settings ...@@ -11,7 +14,8 @@ from django.conf import settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.locator import CourseLocator, Locator from xmodule.modulestore.locator import CourseLocator, Locator
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.exceptions import InvalidLocationError from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError
import itertools
# define a couple of simple roles, we just need ADMIN and EDITOR now for our purposes # define a couple of simple roles, we just need ADMIN and EDITOR now for our purposes
...@@ -26,7 +30,11 @@ COURSE_CREATOR_GROUP_NAME = "course_creator_group" ...@@ -26,7 +30,11 @@ COURSE_CREATOR_GROUP_NAME = "course_creator_group"
# of those two variables # of those two variables
def get_course_groupname_for_role(location, role): def get_all_course_role_groupnames(location, role, use_filter=True):
'''
Get all of the possible groupnames for this role location pair. If use_filter==True,
only return the ones defined in the groups collection.
'''
location = Locator.to_locator_or_location(location) location = Locator.to_locator_or_location(location)
# hack: check for existence of a group name in the legacy LMS format <role>_<course> # hack: check for existence of a group name in the legacy LMS format <role>_<course>
...@@ -38,22 +46,46 @@ def get_course_groupname_for_role(location, role): ...@@ -38,22 +46,46 @@ def get_course_groupname_for_role(location, role):
except InvalidLocationError: # will occur on old locations where location is not of category course except InvalidLocationError: # will occur on old locations where location is not of category course
pass pass
if isinstance(location, Location): if isinstance(location, Location):
# least preferred role_course format
groupnames.append('{0}_{1}'.format(role, location.course)) groupnames.append('{0}_{1}'.format(role, location.course))
try:
locator = loc_mapper().translate_location(location.course_id, location, False, False)
groupnames.append('{0}_{1}'.format(role, locator.course_id))
except (InvalidLocationError, ItemNotFoundError):
pass
elif isinstance(location, CourseLocator): elif isinstance(location, CourseLocator):
old_location = loc_mapper().translate_locator_to_location(location, get_course=True) old_location = loc_mapper().translate_locator_to_location(location, get_course=True)
if old_location: if old_location:
# the slashified version of the course_id (myu/mycourse/myrun)
groupnames.append('{0}_{1}'.format(role, old_location.course_id)) groupnames.append('{0}_{1}'.format(role, old_location.course_id))
# add the least desirable but sometimes occurring format.
for groupname in groupnames: groupnames.append('{0}_{1}'.format(role, old_location.course))
if Group.objects.filter(name=groupname).exists(): # filter to the ones which exist
return groupname default = groupnames[0]
return groupnames[0] if use_filter:
groupnames = [group for group in groupnames if Group.objects.filter(name=group).exists()]
return groupnames, default
def get_users_in_course_group_by_role(location, role): def get_course_groupname_for_role(location, role):
groupname = get_course_groupname_for_role(location, role) '''
(group, _created) = Group.objects.get_or_create(name=groupname) Get the preferred used groupname for this role, location combo.
return group.user_set.all() Preference order:
* role_course_id (e.g., staff_myu.mycourse.myrun)
* role_old_course_id (e.g., staff_myu/mycourse/myrun)
* role_old_course (e.g., staff_mycourse)
'''
groupnames, default = get_all_course_role_groupnames(location, role)
return groupnames[0] if groupnames else default
def get_course_role_users(course_locator, role):
'''
Get all of the users with the given role in the given course.
'''
groupnames, _ = get_all_course_role_groupnames(course_locator, role)
groups = [Group.objects.get(name=groupname) for groupname in groupnames]
return list(itertools.chain.from_iterable(group.user_set.all() for group in groups))
def create_all_course_groups(creator, location): def create_all_course_groups(creator, location):
...@@ -65,11 +97,11 @@ def create_all_course_groups(creator, location): ...@@ -65,11 +97,11 @@ def create_all_course_groups(creator, location):
def create_new_course_group(creator, location, role): def create_new_course_group(creator, location, role):
groupname = get_course_groupname_for_role(location, role) '''
(group, created) = Group.objects.get_or_create(name=groupname) Create the new course group always using the preferred name even if another form already exists.
if created: '''
group.save() groupnames, __ = get_all_course_role_groupnames(location, role, use_filter=False)
group, __ = Group.objects.get_or_create(name=groupnames[0])
creator.groups.add(group) creator.groups.add(group)
creator.save() creator.save()
...@@ -82,15 +114,13 @@ def _delete_course_group(location): ...@@ -82,15 +114,13 @@ def _delete_course_group(location):
asserted permissions asserted permissions
""" """
# remove all memberships # remove all memberships
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
for user in instructors.user_set.all(): groupnames, _ = get_all_course_role_groupnames(location, role)
user.groups.remove(instructors) for groupname in groupnames:
user.save() group = Group.objects.get(name=groupname)
for user in group.user_set.all():
staff = Group.objects.get(name=get_course_groupname_for_role(location, STAFF_ROLE_NAME)) user.groups.remove(group)
for user in staff.user_set.all(): user.save()
user.groups.remove(staff)
user.save()
def _copy_course_group(source, dest): def _copy_course_group(source, dest):
...@@ -98,25 +128,25 @@ def _copy_course_group(source, dest): ...@@ -98,25 +128,25 @@ def _copy_course_group(source, dest):
This is to be called only by either a command line code path or through an app which has already This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action asserted permissions to do this action
""" """
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) groupnames, _ = get_all_course_role_groupnames(source, role)
for user in instructors.user_set.all(): for groupname in groupnames:
user.groups.add(new_instructors_group) group = Group.objects.get(name=groupname)
user.save() new_group, _ = Group.objects.get_or_create(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
for user in group.user_set.all():
staff = Group.objects.get(name=get_course_groupname_for_role(source, STAFF_ROLE_NAME)) user.groups.add(new_group)
new_staff_group = Group.objects.get(name=get_course_groupname_for_role(dest, STAFF_ROLE_NAME)) user.save()
for user in staff.user_set.all():
user.groups.add(new_staff_group)
user.save()
def add_user_to_course_group(caller, user, location, role): def add_user_to_course_group(caller, user, location, role):
"""
If caller is authorized, add the given user to the given course's role
"""
# only admins can add/remove other users # only admins can add/remove other users
if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME): if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME):
raise PermissionDenied raise PermissionDenied
group = Group.objects.get(name=get_course_groupname_for_role(location, role)) group, _ = Group.objects.get_or_create(name=get_course_groupname_for_role(location, role))
return _add_user_to_group(user, group) return _add_user_to_group(user, group)
...@@ -132,9 +162,7 @@ def add_user_to_creator_group(caller, user): ...@@ -132,9 +162,7 @@ def add_user_to_creator_group(caller, user):
if not caller.is_active or not caller.is_authenticated or not caller.is_staff: if not caller.is_active or not caller.is_authenticated or not caller.is_staff:
raise PermissionDenied raise PermissionDenied
(group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME) (group, _) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME)
if created:
group.save()
return _add_user_to_group(user, group) return _add_user_to_group(user, group)
...@@ -152,6 +180,9 @@ def _add_user_to_group(user, group): ...@@ -152,6 +180,9 @@ def _add_user_to_group(user, group):
def get_user_by_email(email): def get_user_by_email(email):
"""
Get the user whose email is the arg. Return None if no such user exists.
"""
user = None user = None
# try to look up user, return None if not found # try to look up user, return None if not found
try: try:
...@@ -163,13 +194,21 @@ def get_user_by_email(email): ...@@ -163,13 +194,21 @@ def get_user_by_email(email):
def remove_user_from_course_group(caller, user, location, role): def remove_user_from_course_group(caller, user, location, role):
"""
If caller is authorized, remove the given course x role authorization for user
"""
# only admins can add/remove other users # only admins can add/remove other users
if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME): if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME):
raise PermissionDenied raise PermissionDenied
# see if the user is actually in that role, if not then we don't have to do anything # see if the user is actually in that role, if not then we don't have to do anything
if is_user_in_course_group_role(user, location, role): groupnames, _ = get_all_course_role_groupnames(location, role)
_remove_user_from_group(user, get_course_groupname_for_role(location, role)) for groupname in groupnames:
groups = user.groups.filter(name=groupname)
if groups:
# will only be one with that name
user.groups.remove(groups[0])
user.save()
def remove_user_from_creator_group(caller, user): def remove_user_from_creator_group(caller, user):
...@@ -195,11 +234,16 @@ def _remove_user_from_group(user, group_name): ...@@ -195,11 +234,16 @@ def _remove_user_from_group(user, group_name):
def is_user_in_course_group_role(user, location, role, check_staff=True): def is_user_in_course_group_role(user, location, role, check_staff=True):
"""
Check whether the given user has the given role in this course. If check_staff
then give permission if the user is staff without doing a course-role query.
"""
if user.is_active and user.is_authenticated: if user.is_active and user.is_authenticated:
# all "is_staff" flagged accounts belong to all groups # all "is_staff" flagged accounts belong to all groups
if check_staff and user.is_staff: if check_staff and user.is_staff:
return True return True
return user.groups.filter(name=get_course_groupname_for_role(location, role)).exists() groupnames, _ = get_all_course_role_groupnames(location, role)
return any(user.groups.filter(name=groupname).exists() for groupname in groupnames)
return False return False
......
"""
Test CRUD for authorization.
"""
from django.test.utils import override_settings
from django.contrib.auth.models import User, Group
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.modulestore_config import TEST_MODULESTORE
from contentstore.tests.utils import AjaxEnabledTestClient
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore import Location
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME
from auth import authz
import copy
from contentstore.views.access import has_access
@override_settings(MODULESTORE=TEST_MODULESTORE)
class TestCourseAccess(ModuleStoreTestCase):
"""
Course-based access (as opposed to access of a non-course xblock)
"""
def setUp(self):
"""
Create a staff user and log them in (creating the client).
Create a pool of users w/o granting them any permissions
"""
super(TestCourseAccess, self).setUp()
uname = 'testuser'
email = 'test+courses@edx.org'
password = 'foo'
# Create the use so we can log them in.
self.user = User.objects.create_user(uname, email, password)
# Note that we do not actually need to do anything
# for registration if we directly mark them active.
self.user.is_active = True
# Staff has access to view all courses
self.user.is_staff = True
self.user.save()
self.client = AjaxEnabledTestClient()
self.client.login(username=uname, password=password)
# create a course via the view handler which has a different strategy for permissions than the factory
self.course_location = Location(['i4x', 'myu', 'mydept.mycourse', 'course', 'myrun'])
self.course_locator = loc_mapper().translate_location(
self.course_location.course_id, self.course_location, False, True
)
self.client.ajax_post(
self.course_locator.url_reverse('course'),
{
'org': self.course_location.org,
'number': self.course_location.course,
'display_name': 'My favorite course',
'run': self.course_location.name,
}
)
self.users = self._create_users()
def _create_users(self):
"""
Create 8 users and return them
"""
users = []
for i in range(8):
username = "user{}".format(i)
email = "test+user{}@edx.org".format(i)
user = User.objects.create_user(username, email, 'foo')
user.is_active = True
user.save()
users.append(user)
return users
def tearDown(self):
"""
Reverse the setup
"""
self.client.logout()
ModuleStoreTestCase.tearDown(self)
def test_get_all_users(self):
"""
Test getting all authors for a course where their permissions run the gamut of allowed group
types.
"""
# first check the groupname for the course creator.
self.assertTrue(
self.user.groups.filter(
name="{}_{}".format(INSTRUCTOR_ROLE_NAME, self.course_locator.course_id)
).exists(),
"Didn't add creator as instructor."
)
users = copy.copy(self.users)
user_by_role = {}
# add the misc users to the course in different groups
for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
user_by_role[role] = []
groupnames, _ = authz.get_all_course_role_groupnames(self.course_locator, role)
for groupname in groupnames:
group, _ = Group.objects.get_or_create(name=groupname)
user = users.pop()
user_by_role[role].append(user)
user.groups.add(group)
user.save()
self.assertTrue(has_access(user, self.course_locator), "{} does not have access".format(user))
self.assertTrue(has_access(user, self.course_location), "{} does not have access".format(user))
response = self.client.get_html(self.course_locator.url_reverse('course_team'))
for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
for user in user_by_role[role]:
self.assertContains(response, user.email)
# test copying course permissions
copy_course_location = Location(['i4x', 'copyu', 'copydept.mycourse', 'course', 'myrun'])
copy_course_locator = loc_mapper().translate_location(
copy_course_location.course_id, copy_course_location, False, True
)
# pylint: disable=protected-access
authz._copy_course_group(self.course_locator, copy_course_locator)
for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
for user in user_by_role[role]:
self.assertTrue(has_access(user, copy_course_locator), "{} no copy access".format(user))
self.assertTrue(has_access(user, copy_course_location), "{} no copy access".format(user))
\ No newline at end of file
...@@ -29,8 +29,8 @@ class UsersTestCase(CourseTestCase): ...@@ -29,8 +29,8 @@ class UsersTestCase(CourseTestCase):
self.detail_url = self.location.url_reverse('course_team', self.ext_user.email) self.detail_url = self.location.url_reverse('course_team', self.ext_user.email)
self.inactive_detail_url = self.location.url_reverse('course_team', self.inactive_user.email) self.inactive_detail_url = self.location.url_reverse('course_team', self.inactive_user.email)
self.invalid_detail_url = self.location.url_reverse('course_team', "nonexistent@user.com") self.invalid_detail_url = self.location.url_reverse('course_team', "nonexistent@user.com")
self.staff_groupname = get_course_groupname_for_role(self.course.location, "staff") self.staff_groupname = get_course_groupname_for_role(self.course_locator, "staff")
self.inst_groupname = get_course_groupname_for_role(self.course.location, "instructor") self.inst_groupname = get_course_groupname_for_role(self.course_locator, "instructor")
def test_index(self): def test_index(self):
resp = self.client.get(self.index_url, HTTP_ACCEPT='text/html') resp = self.client.get(self.index_url, HTTP_ACCEPT='text/html')
...@@ -145,18 +145,6 @@ class UsersTestCase(CourseTestCase): ...@@ -145,18 +145,6 @@ class UsersTestCase(CourseTestCase):
self.assertIn("error", result) self.assertIn("error", result)
self.assert_not_enrolled() self.assert_not_enrolled()
def test_detail_post_bad_json(self):
resp = self.client.post(
self.detail_url,
data="{foo}",
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assertEqual(resp.status_code, 400)
result = json.loads(resp.content)
self.assertIn("error", result)
self.assert_not_enrolled()
def test_detail_post_no_json(self): def test_detail_post_no_json(self):
resp = self.client.post( resp = self.client.post(
self.detail_url, self.detail_url,
......
...@@ -292,7 +292,8 @@ def create_new_course(request): ...@@ -292,7 +292,8 @@ def create_new_course(request):
initialize_course_tabs(new_course) initialize_course_tabs(new_course)
create_all_course_groups(request.user, new_course.location) new_location = loc_mapper().translate_location(new_course.location.course_id, new_course.location, False, True)
create_all_course_groups(request.user, new_location)
# seed the forums # seed the forums
seed_permissions_roles(new_course.location.course_id) seed_permissions_roles(new_course.location.course_id)
...@@ -301,7 +302,6 @@ def create_new_course(request): ...@@ -301,7 +302,6 @@ def create_new_course(request):
# work. # work.
CourseEnrollment.enroll(request.user, new_course.location.course_id) CourseEnrollment.enroll(request.user, new_course.location.course_id)
new_location = loc_mapper().translate_location(new_course.location.course_id, new_course.location, False, True)
return JsonResponse({'url': new_location.url_reverse("course/", "")}) return JsonResponse({'url': new_location.url_reverse("course/", "")})
......
import json import json
from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
...@@ -10,9 +9,11 @@ from django_future.csrf import ensure_csrf_cookie ...@@ -10,9 +9,11 @@ from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore, loc_mapper from xmodule.modulestore.django import modulestore, loc_mapper
from util.json_request import JsonResponse from util.json_request import JsonResponse, expect_json
from auth.authz import ( from auth.authz import (
STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_course_groupname_for_role) STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_course_groupname_for_role,
get_course_role_users
)
from course_creators.views import user_requested_access from course_creators.views import user_requested_access
from .access import has_access from .access import has_access
...@@ -35,6 +36,7 @@ def request_course_creator(request): ...@@ -35,6 +36,7 @@ def request_course_creator(request):
return JsonResponse({"Status": "OK"}) return JsonResponse({"Status": "OK"})
# pylint: disable=unused-argument
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
@require_http_methods(("GET", "POST", "PUT", "DELETE")) @require_http_methods(("GET", "POST", "PUT", "DELETE"))
...@@ -62,38 +64,39 @@ def course_team_handler(request, tag=None, course_id=None, branch=None, version_ ...@@ -62,38 +64,39 @@ def course_team_handler(request, tag=None, course_id=None, branch=None, version_
return HttpResponseNotFound() return HttpResponseNotFound()
def _manage_users(request, location): def _manage_users(request, locator):
""" """
This view will return all CMS users who are editors for the specified course This view will return all CMS users who are editors for the specified course
""" """
old_location = loc_mapper().translate_locator_to_location(location) old_location = loc_mapper().translate_locator_to_location(locator)
# check that logged in user has permissions to this item # check that logged in user has permissions to this item
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): if not has_access(request.user, locator):
raise PermissionDenied() raise PermissionDenied()
course_module = modulestore().get_item(old_location) course_module = modulestore().get_item(old_location)
instructors = get_course_role_users(locator, INSTRUCTOR_ROLE_NAME)
staff_groupname = get_course_groupname_for_role(location, "staff") # the page only lists staff and assumes they're a superset of instructors. Do a union to ensure.
staff_group, __ = Group.objects.get_or_create(name=staff_groupname) staff = set(get_course_role_users(locator, STAFF_ROLE_NAME)).union(instructors)
inst_groupname = get_course_groupname_for_role(location, "instructor")
inst_group, __ = Group.objects.get_or_create(name=inst_groupname)
return render_to_response('manage_users.html', { return render_to_response('manage_users.html', {
'context_course': course_module, 'context_course': course_module,
'staff': staff_group.user_set.all(), 'staff': staff,
'instructors': inst_group.user_set.all(), 'instructors': instructors,
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), 'allow_actions': has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME),
}) })
def _course_team_user(request, location, email): @expect_json
old_location = loc_mapper().translate_locator_to_location(location) def _course_team_user(request, locator, email):
"""
Handle the add, remove, promote, demote requests ensuring the requester has authority
"""
# check that logged in user has permissions to this item # check that logged in user has permissions to this item
if has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): if has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME):
# instructors have full permissions # instructors have full permissions
pass pass
elif has_access(request.user, location, role=STAFF_ROLE_NAME) and email == request.user.email: elif has_access(request.user, locator, role=STAFF_ROLE_NAME) and email == request.user.email:
# staff can only affect themselves # staff can only affect themselves
pass pass
else: else:
...@@ -123,7 +126,7 @@ def _course_team_user(request, location, email): ...@@ -123,7 +126,7 @@ def _course_team_user(request, location, email):
# what's the highest role that this user has? # what's the highest role that this user has?
groupnames = set(g.name for g in user.groups.all()) groupnames = set(g.name for g in user.groups.all())
for role in roles: for role in roles:
role_groupname = get_course_groupname_for_role(old_location, role) role_groupname = get_course_groupname_for_role(locator, role)
if role_groupname in groupnames: if role_groupname in groupnames:
msg["role"] = role msg["role"] = role
break break
...@@ -139,7 +142,7 @@ def _course_team_user(request, location, email): ...@@ -139,7 +142,7 @@ def _course_team_user(request, location, email):
# make sure that the role groups exist # make sure that the role groups exist
groups = {} groups = {}
for role in roles: for role in roles:
groupname = get_course_groupname_for_role(old_location, role) groupname = get_course_groupname_for_role(locator, role)
group, __ = Group.objects.get_or_create(name=groupname) group, __ = Group.objects.get_or_create(name=groupname)
groups[role] = group groups[role] = group
...@@ -162,22 +165,13 @@ def _course_team_user(request, location, email): ...@@ -162,22 +165,13 @@ def _course_team_user(request, location, email):
return JsonResponse() return JsonResponse()
# all other operations require the requesting user to specify a role # all other operations require the requesting user to specify a role
if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body: role = request.json.get("role", request.POST.get("role"))
try: if role is None:
payload = json.loads(request.body) return JsonResponse({"error": _("`role` is required")}, 400)
except:
return JsonResponse({"error": _("malformed JSON")}, 400)
try:
role = payload["role"]
except KeyError:
return JsonResponse({"error": _("`role` is required")}, 400)
else:
if not "role" in request.POST:
return JsonResponse({"error": _("`role` is required")}, 400)
role = request.POST["role"]
old_location = loc_mapper().translate_locator_to_location(locator)
if role == "instructor": if role == "instructor":
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): if not has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME):
msg = { msg = {
"error": _("Only instructors may create other instructors") "error": _("Only instructors may create other instructors")
} }
...@@ -203,4 +197,3 @@ def _course_team_user(request, location, email): ...@@ -203,4 +197,3 @@ def _course_team_user(request, location, email):
CourseEnrollment.enroll(user, old_location.course_id) CourseEnrollment.enroll(user, old_location.course_id)
return JsonResponse() return JsonResponse()
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