Commit 7d7513b6 by David Baumgold

Merge pull request #470 from edx/db/course-team-admin-grants

Grant admin rights to members of the course team
parents 6a7fa41e 0022469e
......@@ -18,6 +18,9 @@ the setting is not present, the API is disabled).
LMS: Added endpoints for AJAX requests to enable/disable notifications
(which are not yet implemented) and a one-click unsubscribe page.
Studio: Allow instructors of a course to designate other staff as instructors;
this allows instructors to hand off management of a course to someone else.
Common: Add a manage.py that knows about edx-platform specific settings and projects
Common: Added *experimental* support for jsinput type.
......
......@@ -178,10 +178,12 @@ def _remove_user_from_group(user, group_name):
user.save()
def is_user_in_course_group_role(user, location, role):
def is_user_in_course_group_role(user, location, role, check_staff=True):
if user.is_active and user.is_authenticated:
# all "is_staff" flagged accounts belong to all groups
return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0
if check_staff and user.is_staff:
return True
return user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0
return False
......
......@@ -53,6 +53,14 @@ def i_have_opened_a_new_course(_step):
open_new_course()
@step('(I select|s?he selects) the new course')
def select_new_course(_step, whom):
course_link_xpath = '//div[contains(@class, "courses")]//a[contains(@class, "class-link")]//span[contains(., "{name}")]/..'.format(
name="Robot Super Course")
element = world.browser.find_by_xpath(course_link_xpath)
element.click()
@step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(_step, name):
css = 'a.action-%s' % name.lower()
......@@ -118,6 +126,8 @@ def create_studio_user(
registration.register(studio_user)
registration.activate()
return studio_user
def fill_in_course_info(
name='Robot Super Course',
......
......@@ -16,7 +16,11 @@ def create_component_instance(step, component_button_css, category,
if has_multiple_templates:
click_component_from_menu(category, boilerplate, expected_css)
assert_equal(1, len(world.css_find(expected_css)))
assert_equal(
1,
len(world.css_find(expected_css)),
"Component instance with css {css} was not created successfully".format(css=expected_css))
@world.absorb
def click_new_component_button(step, component_button_css):
......
Feature: Course Team
As a course author, I want to be able to add others to my team
Scenario: Users can add other users
Scenario: Admins can add other users
Given I have opened a new course in Studio
And the user "alice" exists
And I am viewing the course team settings
......@@ -9,7 +9,7 @@ Feature: Course Team
And "alice" logs in
Then she does see the course on her page
Scenario: Added users cannot delete or add other users
Scenario: Added admins cannot delete or add other users
Given I have opened a new course in Studio
And the user "bob" exists
And I am viewing the course team settings
......@@ -18,7 +18,7 @@ Feature: Course Team
Then he cannot delete users
And he cannot add users
Scenario: Users can delete other users
Scenario: Admins can delete other users
Given I have opened a new course in Studio
And the user "carol" exists
And I am viewing the course team settings
......@@ -27,8 +27,33 @@ Feature: Course Team
And "carol" logs in
Then she does not see the course on her page
Scenario: Users cannot add users that do not exist
Scenario: Admins cannot add users that do not exist
Given I have opened a new course in Studio
And I am viewing the course team settings
When I add "dennis" to the course team
Then I should see "Could not find user by email address" somewhere on the page
Scenario: Admins should be able to make other people into admins
Given I have opened a new course in Studio
And the user "emily" exists
And I am viewing the course team settings
And I add "emily" to the course team
When I make "emily" a course team admin
And "emily" logs in
And she selects the new course
And she views the course team settings
Then "emily" should be marked as an admin
And she can add users
And she can delete users
Scenario: Admins should be able to remove other admins
Given I have opened a new course in Studio
And the user "frank" exists as a course admin
And I am viewing the course team settings
When I remove admin rights from "frank"
And "frank" logs in
And he selects the new course
And he views the course team settings
Then "frank" should not be marked as an admin
And he cannot add users
And he cannot delete users
......@@ -3,65 +3,105 @@
from lettuce import world, step
from common import create_studio_user, log_into_studio
from django.contrib.auth.models import Group
from auth.authz import get_course_groupname_for_role
PASSWORD = 'test'
EMAIL_EXTENSION = '@edx.org'
@step(u'I am viewing the course team settings')
def view_grading_settings(_step):
@step(u'(I am viewing|s?he views) the course team settings')
def view_grading_settings(_step, whom):
world.click_course_settings()
link_css = 'li.nav-course-settings-team a'
world.css_click(link_css)
@step(u'the user "([^"]*)" exists$')
def create_other_user(_step, name):
create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION))
@step(u'the user "([^"]*)" exists( as a course admin)?$')
def create_other_user(_step, name, course_admin):
user = create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION))
if course_admin:
location = world.scenario_dict["COURSE"].location
for role in ("staff", "instructor"):
group, __ = Group.objects.get_or_create(name=get_course_groupname_for_role(location, role))
user.groups.add(group)
user.save()
@step(u'I add "([^"]*)" to the course team')
def add_other_user(_step, name):
new_user_css = 'a.new-user-button'
new_user_css = 'a.create-user-button'
world.css_click(new_user_css)
world.wait(0.5)
email_css = 'input.email-input'
email_css = 'input#user-email-input'
f = world.css_find(email_css)
f._element.send_keys(name, EMAIL_EXTENSION)
confirm_css = '#add_user'
confirm_css = 'form.create-user button.action-primary'
world.css_click(confirm_css)
@step(u'I delete "([^"]*)" from the course team')
def delete_other_user(_step, name):
to_delete_css = 'a.remove-user[data-id="{name}{extension}"]'.format(name=name, extension=EMAIL_EXTENSION)
to_delete_css = '.user-item .item-actions a.remove-user[data-id="{email}"]'.format(
email="{0}{1}".format(name, EMAIL_EXTENSION))
world.css_click(to_delete_css)
@step(u'I make "([^"]*)" a course team admin')
def make_course_team_admin(_step, name):
admin_btn_css = '.user-item[data-email="{email}"] .user-actions .add-admin-role'.format(
email=name+EMAIL_EXTENSION)
world.css_click(admin_btn_css)
@step(u'I remove admin rights from "([^"]*)"')
def remove_course_team_admin(_step, name):
admin_btn_css = '.user-item[data-email="{email}"] .user-actions .remove-admin-role'.format(
email=name+EMAIL_EXTENSION)
world.css_click(admin_btn_css)
@step(u'"([^"]*)" logs in$')
def other_user_login(_step, name):
log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION)
@step(u's?he does( not)? see the course on (his|her) page')
def see_course(_step, doesnt_see_course, gender):
def see_course(_step, inverted, gender):
class_css = 'span.class-name'
all_courses = world.css_find(class_css, wait_time=1)
all_names = [item.html for item in all_courses]
if doesnt_see_course:
if inverted:
assert not world.scenario_dict['COURSE'].display_name in all_names
else:
assert world.scenario_dict['COURSE'].display_name in all_names
@step(u's?he cannot delete users')
def cannot_delete(_step):
@step(u'"([^"]*)" should( not)? be marked as an admin')
def marked_as_admin(_step, name, inverted):
flag_css = '.user-item[data-email="{email}"] .flag-role.flag-role-admin'.format(
email=name+EMAIL_EXTENSION)
if inverted:
assert world.is_css_not_present(flag_css)
else:
assert world.is_css_present(flag_css)
@step(u's?he can(not)? delete users')
def can_delete_users(_step, inverted):
to_delete_css = 'a.remove-user'
assert world.is_css_not_present(to_delete_css)
if inverted:
assert world.is_css_not_present(to_delete_css)
else:
assert world.is_css_present(to_delete_css)
@step(u's?he cannot add users')
def cannot_add(_step):
add_css = 'a.new-user'
assert world.is_css_not_present(add_css)
@step(u's?he can(not)? add users')
def can_add_users(_step, inverted):
add_css = 'a.create-user-button'
if inverted:
assert world.is_css_not_present(add_css)
else:
assert world.is_css_present(add_css)
""" Unit tests for checklist methods in views.py. """
from contentstore.utils import get_modulestore, get_url_reverse
from contentstore.utils import get_modulestore
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
......@@ -38,7 +38,11 @@ class ChecklistTestCase(CourseTestCase):
def test_get_checklists(self):
""" Tests the get checklists method. """
checklists_url = get_url_reverse('Checklists', self.course)
checklists_url = reverse("checklists", kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
})
response = self.client.get(checklists_url)
self.assertContains(response, "Getting Started With Studio")
payload = response.content
......
......@@ -1167,7 +1167,9 @@ class ContentStoreTest(ModuleStoreTestCase):
# manage users
resp = self.client.get(reverse('manage_users',
kwargs={'location': loc.url()}))
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
# course info
......
"""
Tests for user.py.
"""
import json
import mock
from .utils import CourseTestCase
from django.contrib.auth.models import User, Group
from django.core.urlresolvers import reverse
from contentstore.views.user import _get_course_creator_status
from course_creators.views import add_user_with_status_granted
from course_creators.admin import CourseCreatorAdmin
from course_creators.models import CourseCreator
from django.http import HttpRequest
from django.contrib.auth.models import User
from django.contrib.admin.sites import AdminSite
from auth.authz import get_course_groupname_for_role
class UsersTestCase(CourseTestCase):
def setUp(self):
super(UsersTestCase, self).setUp()
self.url = reverse("add_user", kwargs={"location": ""})
self.ext_user = User.objects.create_user(
"joe", "joe@comedycentral.com", "haha")
self.ext_user.is_active = True
self.ext_user.is_staff = False
self.ext_user.save()
self.inactive_user = User.objects.create_user(
"carl", "carl@comedycentral.com", "haha")
self.inactive_user.is_active = False
self.inactive_user.is_staff = False
self.inactive_user.save()
self.index_url = reverse("manage_users", kwargs={
"org": self.course.location.org,
"course": self.course.location.course,
"name": self.course.location.name,
})
self.detail_url = reverse("course_team_user", kwargs={
"org": self.course.location.org,
"course": self.course.location.course,
"name": self.course.location.name,
"email": self.ext_user.email,
})
self.inactive_detail_url = reverse("course_team_user", kwargs={
"org": self.course.location.org,
"course": self.course.location.course,
"name": self.course.location.name,
"email": self.inactive_user.email,
})
self.invalid_detail_url = reverse("course_team_user", kwargs={
"org": self.course.location.org,
"course": self.course.location.course,
"name": self.course.location.name,
"email": "nonexistent@user.com",
})
self.staff_groupname = get_course_groupname_for_role(self.course.location, "staff")
self.inst_groupname = get_course_groupname_for_role(self.course.location, "instructor")
def test_index(self):
resp = self.client.get(self.index_url)
# ext_user is not currently a member of the course team, and so should
# not show up on the page.
self.assertNotContains(resp, self.ext_user.email)
def test_index_member(self):
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
self.ext_user.groups.add(group)
self.ext_user.save()
resp = self.client.get(self.index_url)
self.assertContains(resp, self.ext_user.email)
def test_detail(self):
resp = self.client.get(self.detail_url)
self.assertEqual(resp.status_code, 200)
result = json.loads(resp.content)
self.assertEqual(result["role"], None)
self.assertTrue(result["active"])
def test_detail_inactive(self):
resp = self.client.get(self.inactive_detail_url)
self.assert2XX(resp.status_code)
result = json.loads(resp.content)
self.assertFalse(result["active"])
def test_detail_invalid(self):
resp = self.client.get(self.invalid_detail_url)
self.assert4XX(resp.status_code)
result = json.loads(resp.content)
self.assertIn("error", result)
def test_detail_post(self):
resp = self.client.post(
self.detail_url,
data={"role": None},
)
self.assert2XX(resp.status_code)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
# no content: should not be in any roles
self.assertNotIn(self.staff_groupname, groups)
self.assertNotIn(self.inst_groupname, groups)
def test_detail_post_staff(self):
resp = self.client.post(
self.detail_url,
data=json.dumps({"role": "staff"}),
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.staff_groupname, groups)
self.assertNotIn(self.inst_groupname, groups)
def test_detail_post_staff_other_inst(self):
inst_group, _ = Group.objects.get_or_create(name=self.inst_groupname)
self.user.groups.add(inst_group)
self.user.save()
def test_empty(self):
resp = self.client.post(self.url)
resp = self.client.post(
self.detail_url,
data=json.dumps({"role": "staff"}),
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.staff_groupname, groups)
self.assertNotIn(self.inst_groupname, groups)
# check that other user is unchanged
user = User.objects.get(email=self.user.email)
groups = [g.name for g in user.groups.all()]
self.assertNotIn(self.staff_groupname, groups)
self.assertIn(self.inst_groupname, groups)
def test_detail_post_instructor(self):
resp = self.client.post(
self.detail_url,
data=json.dumps({"role": "instructor"}),
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertNotIn(self.staff_groupname, groups)
self.assertIn(self.inst_groupname, groups)
def test_detail_post_missing_role(self):
resp = self.client.post(
self.detail_url,
data=json.dumps({"toys": "fun"}),
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert4XX(resp.status_code)
result = json.loads(resp.content)
self.assertIn("error", result)
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.assert4XX(resp.status_code)
result = json.loads(resp.content)
self.assertIn("error", result)
def test_detail_post_no_json(self):
resp = self.client.post(
self.detail_url,
data={"role": "staff"},
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.staff_groupname, groups)
self.assertNotIn(self.inst_groupname, groups)
def test_detail_delete_staff(self):
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
self.ext_user.groups.add(group)
self.ext_user.save()
resp = self.client.delete(
self.detail_url,
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertNotIn(self.staff_groupname, groups)
def test_detail_delete_instructor(self):
group, _ = Group.objects.get_or_create(name=self.inst_groupname)
self.user.groups.add(group)
self.ext_user.groups.add(group)
self.user.save()
self.ext_user.save()
resp = self.client.delete(
self.detail_url,
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertNotIn(self.inst_groupname, groups)
def test_delete_last_instructor(self):
group, _ = Group.objects.get_or_create(name=self.inst_groupname)
self.ext_user.groups.add(group)
self.ext_user.save()
resp = self.client.delete(
self.detail_url,
HTTP_ACCEPT="application/json",
)
self.assertEqual(resp.status_code, 400)
content = json.loads(resp.content)
self.assertEqual(content["Status"], "Failed")
class IndexCourseCreatorTests(CourseTestCase):
"""
Tests the various permutations of course creator status.
"""
def setUp(self):
super(IndexCourseCreatorTests, self).setUp()
self.index_url = reverse("index")
self.request_access_url = reverse("request_course_creator")
# Disable course creation takes precedence over enable creator group. I have enabled the
# latter to make this clear.
self.disable_course_creation = {
"DISABLE_COURSE_CREATION": True,
"ENABLE_CREATOR_GROUP": True,
'STUDIO_REQUEST_EMAIL': 'mark@marky.mark',
}
self.enable_creator_group = {"ENABLE_CREATOR_GROUP": True}
self.admin = User.objects.create_user('Mark', 'mark+courses@edx.org', 'foo')
self.admin.is_staff = True
def test_get_course_creator_status_disable_creation(self):
# DISABLE_COURSE_CREATION is True (this is the case on edx, where we have a marketing site).
# Only edx staff can create courses.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation):
self.assertTrue(self.user.is_staff)
self.assertEquals('granted', _get_course_creator_status(self.user))
self._set_user_non_staff()
self.assertFalse(self.user.is_staff)
self.assertEquals('disallowed_for_this_site', _get_course_creator_status(self.user))
def test_get_course_creator_status_default_cause(self):
# Neither ENABLE_CREATOR_GROUP nor DISABLE_COURSE_CREATION are enabled. Anyone can create a course.
self.assertEquals('granted', _get_course_creator_status(self.user))
self._set_user_non_staff()
self.assertEquals('granted', _get_course_creator_status(self.user))
def test_get_course_creator_status_creator_group(self):
# ENABLE_CREATOR_GROUP is True. This is the case on edge.
# Only staff members and users who have been granted access can create courses.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
# Staff members can always create courses.
self.assertEquals('granted', _get_course_creator_status(self.user))
# Non-staff must request access.
self._set_user_non_staff()
self.assertEquals('unrequested', _get_course_creator_status(self.user))
# Staff user requests access.
self.client.post(self.request_access_url)
self.assertEquals('pending', _get_course_creator_status(self.user))
def test_get_course_creator_status_creator_group_granted(self):
# ENABLE_CREATOR_GROUP is True. This is the case on edge.
# Check return value for a non-staff user who has been granted access.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
self._set_user_non_staff()
add_user_with_status_granted(self.admin, self.user)
self.assertEquals('granted', _get_course_creator_status(self.user))
def test_get_course_creator_status_creator_group_denied(self):
# ENABLE_CREATOR_GROUP is True. This is the case on edge.
# Check return value for a non-staff user who has been denied access.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
self._set_user_non_staff()
self._set_user_denied()
self.assertEquals('denied', _get_course_creator_status(self.user))
def test_disable_course_creation_enabled_non_staff(self):
# Test index page content when DISABLE_COURSE_CREATION is True, non-staff member.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation):
self._set_user_non_staff()
self._assert_cannot_create()
def test_disable_course_creation_enabled_staff(self):
# Test index page content when DISABLE_COURSE_CREATION is True, staff member.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation):
resp = self._assert_can_create()
self.assertFalse('Email staff to create course' in resp.content)
def test_can_create_by_default(self):
# Test index page content with neither ENABLE_CREATOR_GROUP nor DISABLE_COURSE_CREATION enabled.
# Anyone can create a course.
self._assert_can_create()
self._set_user_non_staff()
self._assert_can_create()
def test_course_creator_group_enabled(self):
# Test index page content with ENABLE_CREATOR_GROUP True.
# Staff can always create a course, others must request access.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
# Staff members can always create courses.
self._assert_can_create()
# Non-staff case.
self._set_user_non_staff()
resp = self._assert_cannot_create()
self.assertTrue(self.request_access_url in resp.content)
# Now request access.
self.client.post(self.request_access_url)
# Still cannot create a course, but the "request access button" is no longer there.
resp = self._assert_cannot_create()
self.assertFalse(self.request_access_url in resp.content)
self.assertTrue('has-status is-pending' in resp.content)
def test_course_creator_group_granted(self):
# Test index page content with ENABLE_CREATOR_GROUP True, non-staff member with access granted.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
self._set_user_non_staff()
add_user_with_status_granted(self.admin, self.user)
self._assert_can_create()
def test_course_creator_group_denied(self):
# Test index page content with ENABLE_CREATOR_GROUP True, non-staff member with access denied.
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
self._set_user_non_staff()
self._set_user_denied()
resp = self._assert_cannot_create()
self.assertFalse(self.request_access_url in resp.content)
self.assertTrue('has-status is-denied' in resp.content)
def _assert_can_create(self):
"""
Helper method that posts to the index page and checks that the user can create a course.
Returns the response from the post.
"""
resp = self.client.post(self.index_url)
self.assertTrue('new-course-button' in resp.content)
self.assertFalse(self.request_access_url in resp.content)
self.assertFalse('Email staff to create course' in resp.content)
return resp
def _assert_cannot_create(self):
"""
Helper method that posts to the index page and checks that the user cannot create a course.
Returns the response from the post.
"""
resp = self.client.post(self.index_url)
self.assertFalse('new-course-button' in resp.content)
return resp
def _set_user_non_staff(self):
"""
Sets user as non-staff.
"""
result = json.loads(resp.content)
self.assertIn("error", result)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.inst_groupname, groups)
def test_post_last_instructor(self):
group, _ = Group.objects.get_or_create(name=self.inst_groupname)
self.ext_user.groups.add(group)
self.ext_user.save()
resp = self.client.post(
self.detail_url,
data={"role": "staff"},
HTTP_ACCEPT="application/json",
)
self.assertEqual(resp.status_code, 400)
result = json.loads(resp.content)
self.assertIn("error", result)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.inst_groupname, groups)
def test_permission_denied_self(self):
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
self.user.groups.add(group)
self.user.is_staff = False
self.user.save()
def _set_user_denied(self):
"""
Sets course creator status to denied in admin table.
"""
self.table_entry = CourseCreator(user=self.user)
self.table_entry.save()
self.deny_request = HttpRequest()
self.deny_request.user = self.admin
self_url = reverse("course_team_user", kwargs={
"org": self.course.location.org,
"course": self.course.location.course,
"name": self.course.location.name,
"email": self.user.email,
})
resp = self.client.post(
self_url,
data={"role": "instructor"},
HTTP_ACCEPT="application/json",
)
self.assert4XX(resp.status_code)
result = json.loads(resp.content)
self.assertIn("error", result)
def test_permission_denied_other(self):
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
self.user.groups.add(group)
self.user.is_staff = False
self.user.save()
self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite())
resp = self.client.post(
self.detail_url,
data={"role": "instructor"},
HTTP_ACCEPT="application/json",
)
self.assert4XX(resp.status_code)
result = json.loads(resp.content)
self.assertIn("error", result)
def test_staff_can_delete_self(self):
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
self.user.groups.add(group)
self.user.is_staff = False
self.user.save()
self.table_entry.state = CourseCreator.DENIED
self.creator_admin.save_model(self.deny_request, self.table_entry, None, True)
self_url = reverse("course_team_user", kwargs={
"org": self.course.location.org,
"course": self.course.location.course,
"name": self.course.location.name,
"email": self.user.email,
})
resp = self.client.delete(self_url)
self.assert2XX(resp.status_code)
# reload user from DB
user = User.objects.get(email=self.user.email)
groups = [g.name for g in user.groups.all()]
self.assertNotIn(self.staff_groupname, groups)
def test_staff_cannot_delete_other(self):
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
self.user.groups.add(group)
self.user.is_staff = False
self.user.save()
self.ext_user.groups.add(group)
self.ext_user.save()
resp = self.client.delete(self.detail_url)
self.assert4XX(resp.status_code)
result = json.loads(resp.content)
self.assertIn("error", result)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.staff_groupname, groups)
......@@ -72,50 +72,6 @@ class LMSLinksTestCase(TestCase):
)
class UrlReverseTestCase(ModuleStoreTestCase):
""" Tests for get_url_reverse """
def test_course_page_names(self):
""" Test the defined course pages. """
course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course')
self.assertEquals(
'/manage_users/i4x://mitX/666/course/URL_Reverse_Course',
utils.get_url_reverse('ManageUsers', course)
)
self.assertEquals(
'/mitX/666/settings-details/URL_Reverse_Course',
utils.get_url_reverse('SettingsDetails', course)
)
self.assertEquals(
'/mitX/666/settings-grading/URL_Reverse_Course',
utils.get_url_reverse('SettingsGrading', course)
)
self.assertEquals(
'/mitX/666/course/URL_Reverse_Course',
utils.get_url_reverse('CourseOutline', course)
)
self.assertEquals(
'/mitX/666/checklists/URL_Reverse_Course',
utils.get_url_reverse('Checklists', course)
)
def test_unknown_passes_through(self):
""" Test that unknown values pass through. """
course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course')
self.assertEquals(
'foobar',
utils.get_url_reverse('foobar', course)
)
self.assertEquals(
'https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about',
utils.get_url_reverse('https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', course)
)
class ExtraPanelTabTestCase(TestCase):
""" Tests adding and removing extra course tabs. """
......
......@@ -188,38 +188,6 @@ def update_item(location, value):
get_modulestore(location).update_item(location, value)
def get_url_reverse(course_page_name, course_module):
"""
Returns the course URL link to the specified location. This value is suitable to use as an href link.
course_page_name should correspond to an attribute in CoursePageNames (for example, 'ManageUsers'
or 'SettingsDetails'), or else it will simply be returned. This method passes back unknown values of
course_page_names so that it can also be used for absolute (known) URLs.
course_module is used to obtain the location, org, course, and name properties for a course, if
course_page_name corresponds to an attribute in CoursePageNames.
"""
url_name = getattr(CoursePageNames, course_page_name, None)
ctx_loc = course_module.location
if CoursePageNames.ManageUsers == url_name:
return reverse(url_name, kwargs={"location": ctx_loc})
elif url_name in [CoursePageNames.SettingsDetails, CoursePageNames.SettingsGrading,
CoursePageNames.CourseOutline, CoursePageNames.Checklists]:
return reverse(url_name, kwargs={'org': ctx_loc.org, 'course': ctx_loc.course, 'name': ctx_loc.name})
else:
return course_page_name
class CoursePageNames:
""" Constants for pages that are recognized by get_url_reverse method. """
ManageUsers = "manage_users"
SettingsDetails = "settings_details"
SettingsGrading = "settings_grading"
CourseOutline = "course_index"
Checklists = "checklists"
def add_extra_panel_tab(tab_type, course):
"""
Used to add the panel tab to a course if it does not exist.
......
......@@ -29,7 +29,6 @@ from xmodule.util.date_utils import get_default_time_display
from xmodule.modulestore import InvalidLocationError
from xmodule.exceptions import NotFoundError
from ..utils import get_url_reverse
from .access import get_location_and_verify_access
from util.json_request import JsonResponse
......@@ -320,7 +319,11 @@ def import_course(request, org, course, name):
return render_to_response('import.html', {
'context_course': course_module,
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
'successful_import_redirect_url': reverse('course_index', kwargs={
'org': location.org,
'course': location.course,
'name': location.name,
})
})
......
......@@ -4,12 +4,13 @@ from util.json_request import JsonResponse
from django.http import HttpResponseBadRequest
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.core.urlresolvers import reverse
from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.inheritance import own_metadata
from ..utils import get_modulestore, get_url_reverse
from ..utils import get_modulestore
from .access import get_location_and_verify_access
from xmodule.course_module import CourseDescriptor
......@@ -96,10 +97,25 @@ def expand_checklist_action_urls(course_module):
"""
checklists = course_module.checklists
modified = False
urlconf_map = {
"ManageUsers": "manage_users",
"SettingsDetails": "settings_details",
"SettingsGrading": "settings_grading",
"CourseOutline": "course_index",
"Checklists": "checklists",
}
for checklist in checklists:
if not checklist.get('action_urls_expanded', False):
for item in checklist.get('items'):
item['action_url'] = get_url_reverse(item.get('action_url'), course_module)
action_url = item.get('action_url')
if action_url not in urlconf_map:
continue
urlconf_name = urlconf_map[action_url]
item['action_url'] = reverse(urlconf_name, kwargs={
'org': course_module.location.org,
'course': course_module.location.course,
'name': course_module.location.name,
})
checklist['action_urls_expanded'] = True
modified = True
......
import json
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User, Group
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_POST
from django_future.csrf import ensure_csrf_cookie
......@@ -9,10 +12,13 @@ from mitxmako.shortcuts import render_to_response
from django.core.context_processors import csrf
from xmodule.modulestore.django import modulestore
from contentstore.utils import get_url_reverse, get_lms_link_for_item
from util.json_request import expect_json, JsonResponse
from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
from xmodule.modulestore import Location
from contentstore.utils import get_lms_link_for_item
from util.json_request import JsonResponse
from auth.authz import (
STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME,
add_user_to_course_group, remove_user_from_course_group,
get_course_groupname_for_role)
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested, user_requested_access
from .access import has_access
......@@ -36,11 +42,22 @@ def index(request):
and course.location.name != '')
courses = filter(course_filter, courses)
def format_course_for_view(course):
return (
course.display_name,
reverse("course_index", kwargs={
'org': course.location.org,
'course': course.location.course,
'name': course.location.name,
}),
get_lms_link_for_item(
course.location,
course_id=course.location.course_id,
),
)
return render_to_response('index.html', {
'courses': [(course.display_name,
get_url_reverse('CourseOutline', course),
get_lms_link_for_item(course.location, course_id=course.location.course_id))
for course in courses],
'courses': [format_course_for_view(c) for c in courses],
'user': request.user,
'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
......@@ -60,104 +77,141 @@ def request_course_creator(request):
@login_required
@ensure_csrf_cookie
def manage_users(request, location):
def manage_users(request, org, course, name):
'''
This view will return all CMS users who are editors for the specified course
'''
location = Location('i4x', org, course, 'course', name)
# 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):
raise PermissionDenied()
course_module = modulestore().get_item(location)
staff_groupname = get_course_groupname_for_role(location, "staff")
staff_group, __ = Group.objects.get_or_create(name=staff_groupname)
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', {
'context_course': course_module,
'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME),
'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'),
'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'),
'staff': staff_group.user_set.all(),
'instructors': inst_group.user_set.all(),
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
'request_user_id': request.user.id
})
@expect_json
@login_required
@ensure_csrf_cookie
def add_user(request, location):
'''
This POST-back view will add a user - specified by email - to the list of editors for
the specified course
'''
email = request.POST.get("email")
if not email:
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
def course_team_user(request, org, course, name, email):
location = Location('i4x', org, course, 'course', name)
# check that logged in user has permissions to this item
if has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
# instructors have full permissions
pass
elif has_access(request.user, location, role=STAFF_ROLE_NAME) and email == request.user.email:
# staff can only affect themselves
pass
else:
msg = {
'Status': 'Failed',
'ErrMsg': _('Please specify an email address.'),
"error": _("Insufficient permissions")
}
return JsonResponse(msg, 400)
# remove leading/trailing whitespace if necessary
email = email.strip()
# check that logged in user has admin permissions to this course
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
raise PermissionDenied()
user = get_user_by_email(email)
# user doesn't exist?!? Return error.
if user is None:
try:
user = User.objects.get(email=email)
except:
msg = {
'Status': 'Failed',
'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email),
"error": _("Could not find user by email address '{email}'.").format(email=email),
}
return JsonResponse(msg, 404)
# user exists, but hasn't activated account?!?
if not user.is_active:
# role hierarchy: "instructor" has more permissions than "staff" (in a course)
roles = ["instructor", "staff"]
if request.method == "GET":
# just return info about the user
msg = {
'Status': 'Failed',
'ErrMsg': _('User {email} has registered but has not yet activated his/her account.').format(email=email),
"email": user.email,
"active": user.is_active,
"role": None,
}
return JsonResponse(msg, 400)
# ok, we're cool to add to the course group
add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME)
return JsonResponse({"Status": "OK"})
@expect_json
@login_required
@ensure_csrf_cookie
def remove_user(request, location):
'''
This POST-back view will remove a user - specified by email - from the list of editors for
the specified course
'''
email = request.POST["email"]
# check that logged in user has admin permissions on this course
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
raise PermissionDenied()
user = get_user_by_email(email)
if user is None:
# what's the highest role that this user has?
groupnames = set(g.name for g in user.groups.all())
for role in roles:
role_groupname = get_course_groupname_for_role(location, role)
if role_groupname in groupnames:
msg["role"] = role
break
return JsonResponse(msg)
# can't modify an inactive user
if not user.is_active:
msg = {
'Status': 'Failed',
'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email),
"error": _('User {email} has registered but has not yet activated his/her account.').format(email=email),
}
return JsonResponse(msg, 404)
# make sure we're not removing ourselves
if user.id == request.user.id:
raise PermissionDenied()
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
return JsonResponse(msg, 400)
return JsonResponse({"Status": "OK"})
# make sure that the role groups exist
staff_groupname = get_course_groupname_for_role(location, "staff")
staff_group, __ = Group.objects.get_or_create(name=staff_groupname)
inst_groupname = get_course_groupname_for_role(location, "instructor")
inst_group, __ = Group.objects.get_or_create(name=inst_groupname)
if request.method == "DELETE":
# remove all roles in this course from this user: but fail if the user
# is the last instructor in the course team
instructors = set(inst_group.user_set.all())
staff = set(staff_group.user_set.all())
if user in instructors and len(instructors) == 1:
msg = {
"error": _("You may not remove the last instructor from a course")
}
return JsonResponse(msg, 400)
if user in instructors:
user.groups.remove(inst_group)
if user in staff:
user.groups.remove(staff_group)
user.save()
return JsonResponse()
# all other operations require the requesting user to specify a role
if request.META.get("CONTENT_TYPE", "") == "application/json" and request.body:
try:
payload = json.loads(request.body)
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"]
if role == "instructor":
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
msg = {
"error": _("Only instructors may create other instructors")
}
return JsonResponse(msg, 400)
add_user_to_course_group(request.user, user, location, role)
elif role == "staff":
# if we're trying to downgrade a user from "instructor" to "staff",
# make sure we have at least one other instructor in the course team.
instructors = set(inst_group.user_set.all())
if user in instructors:
if len(instructors) == 1:
msg = {
"error": _("You may not remove the last instructor from a course")
}
return JsonResponse(msg, 400)
remove_user_from_course_group(request.user, user, location, "instructor")
add_user_to_course_group(request.user, user, location, role)
return JsonResponse()
def _get_course_creator_status(user):
......
......@@ -93,6 +93,234 @@ form {
}
}
// ELEM: form wrapper
.wrapper-create-element {
height: 0;
margin-bottom: $baseline;
opacity: 0.0;
pointer-events: none;
overflow: hidden;
&.animate {
@include transition(opacity $tmg-f1 ease-in-out 0s, height $tmg-f1 ease-in-out 0s);
}
&.is-shown {
height: auto; // define a specific height for the animating version of this UI to work properly
opacity: 1.0;
pointer-events: auto;
}
}
// ELEM: form
// form styling for creating a new content item (course, user, textbook)
form[class^="create-"] {
@extend .ui-window;
@include box-sizing(border-box);
border-radius: 2px;
width: 100%;
background: $white;
.title {
@extend .t-title4;
font-weight: 600;
padding: $baseline ($baseline*1.5) 0 ($baseline*1.5);
}
fieldset {
padding: $baseline ($baseline*1.5);
}
.list-input {
@extend .cont-no-list;
.field {
margin: 0 0 ($baseline*0.75) 0;
&:last-child {
margin-bottom: 0;
}
&.required {
label {
font-weight: 600;
}
label:after {
margin-left: ($baseline/4);
content: "*";
}
}
label, input, textarea {
display: block;
}
label {
@extend .t-copy-sub1;
@include transition(color $tmg-f3 ease-in-out 0s);
margin: 0 0 ($baseline/4) 0;
&.is-focused {
color: $blue;
}
}
input, textarea {
@include transition(all $tmg-f2 ease-in-out 0s);
@extend .t-copy-base;
height: 100%;
width: 100%;
padding: ($baseline/2);
&.long {
width: 100%;
}
&.short {
width: 25%;
}
&:focus {
+ .tip {
color: $gray;
}
}
}
textarea.long {
height: ($baseline*5);
}
input[type="checkbox"] {
display: inline-block;
margin-right: ($baseline/4);
width: auto;
height: auto;
& + label {
display: inline-block;
}
}
.tip {
@extend .t-copy-sub2;
@include transition(color, 0.15s, ease-in-out);
display: block;
margin-top: ($baseline/4);
color: $gray-l3;
}
.tip-error {
display: none;
float: none;
}
&.error {
label {
color: $red;
}
.tip-error {
@extend .anim-fadeIn;
display: block;
color: $red;
}
input {
border-color: $red;
}
}
}
.field-inline {
input, textarea, select {
width: 62%;
display: inline-block;
}
.tip-stacked {
display: inline-block;
float: right;
width: 35%;
margin-top: 0;
}
&.error {
.tip-error {
}
}
}
.field-group {
@include clearfix();
margin: 0 0 ($baseline/2) 0;
.field {
display: block;
width: 47%;
border-bottom: none;
margin: 0 ($baseline*0.75) 0 0;
padding: ($baseline/4) 0 0 0;
float: left;
position: relative;
&:nth-child(odd) {
float: left;
}
&:nth-child(even) {
float: right;
margin-right: 0;
}
input, textarea {
width: 100%;
}
}
}
}
.actions {
box-shadow: inset 0 1px 2px $shadow;
margin-top: ($baseline*0.75);
border-top: 1px solid $gray-l1;
padding: ($baseline*0.75) ($baseline*1.5);
background: $gray-l6;
.action-primary {
@include blue-button;
@extend .t-action2;
@include transition(all .15s);
display: inline-block;
padding: ($baseline/5) $baseline;
font-weight: 600;
text-transform: uppercase;
}
.action-secondary {
@include grey-button;
@extend .t-action2;
@include transition(all .15s);
display: inline-block;
padding: ($baseline/5) $baseline;
font-weight: 600;
text-transform: uppercase;
}
}
}
// ====================
// forms - grandfathered
......
// studio - elements - icons
// studio - elements - icons & badges
// ====================
.icon {
......@@ -14,3 +14,45 @@
vertical-align: middle;
margin-right: ($baseline/4);
}
// ui - badges
.wrapper-ui-badge {
position: absolute;
top: -1px;
left: ($baseline*1.5);
width: 100%;
}
.ui-badge {
@extend .t-title9;
position: relative;
border-bottom-right-radius: ($baseline/10);
border-bottom-left-radius: ($baseline/10);
padding: ($baseline/4) ($baseline/2) ($baseline/4) ($baseline/2);
font-weight: 600;
text-transform: uppercase;
* [class^="icon-"] {
margin-right: ($baseline/5);
}
// OPTION: add this class for a visual hanging display
&.is-hanging {
@include box-sizing(border-box);
@extend .ui-depth2;
top: -($baseline/4);
&:after {
position: absolute;
top: 0;
right: -($baseline/4);
display: block;
height: 0;
width: 0;
border-bottom: ($baseline/4) solid $black-t3;
border-right: ($baseline/4) solid transparent;
content: "";
opacity: 0.5;
}
}
}
......@@ -55,8 +55,8 @@
margin-bottom: $baseline;
.title {
@extend .t-title7;
margin-bottom: ($baseline/4);
@extend .t-title6;
margin-bottom: ($baseline/2);
font-weight: 700;
}
......@@ -167,6 +167,34 @@
}
}
// particular notice - create
.notice-create {
background-color: $gray-l4;
.title {
color: $gray-d2;
}
.copy {
color: $gray-d2;
}
&.has-actions {
.list-actions {
.action-item {
}
.action-primary {
@extend .btn-primary-green;
@extend .t-action3;
}
}
}
}
// particular notice - confirmation
.notice-confirmation {
background-color: $green-l5;
......
......@@ -30,7 +30,7 @@ body.course.textbooks {
}
.textbook {
@extend .window;
@extend .ui-window;
position: relative;
.view-textbook {
......
......@@ -3,80 +3,227 @@
body.course.users {
.new-user-form {
display: none;
padding: 15px 20px;
background-color: $lightBluishGrey2;
#result {
display: none;
float: left;
margin-bottom: 15px;
padding: 3px 15px;
border-radius: 3px;
background: $error-red;
font-size: 14px;
color: #fff;
}
// LAYOUT: page
.content-primary, .content-supplementary {
@include box-sizing(border-box);
float: left;
}
.form-elements {
clear: both;
}
.content-primary {
width: flex-grid(9, 12);
margin-right: flex-gutter();
}
label {
display: inline-block;
margin-right: 10px;
}
.content-supplementary {
width: flex-grid(3, 12);
}
.email-input {
width: 350px;
padding: 8px 8px 10px;
border-color: $darkGrey;
// ELEM: content
.content {
.introduction {
@extend .t-copy-sub1;
margin: 0 0 ($baseline*2) 0;
}
}
// ELEM: no users notice
.content .notice-create {
width: flexgrid(9, 9);
margin-top: $baseline;
// CASE: notice has actions {
&.has-actions {
.msg, .list-actions {
display: inline-block;
vertical-align: middle;
}
.msg {
width: flex-grid(6, 9);
margin-right: flex-gutter();
}
.list-actions {
width: flex-grid(3, 9);
text-align: right;
margin-top: 0;
.action-item {
}
.add-button {
@include blue-button;
padding: 5px 20px 9px;
.action-primary {
@include green-button(); // overwriting for the sake of syncing older green button styles for now
@extend .t-action3;
padding: ($baseline/2) $baseline;
}
}
}
}
.cancel-button {
@include white-button;
padding: 5px 20px 9px;
// ELEM: new user form
.wrapper-create-user {
&.is-shown {
height: ($baseline*15);
}
}
// ELEM: listing of users
.user-list, .user-item, .item-metadata, .item-actions {
@include box-sizing(border-box);
}
.user-list {
border: 1px solid $mediumGrey;
background: #fff;
li {
.user-item {
@extend .ui-window;
@include clearfix();
position: relative;
padding: 20px;
border-bottom: 1px solid $mediumGrey;
width: flex-grid(9, 9);
margin: 0 0 ($baseline/2) 0;
padding: ($baseline*1.25) ($baseline*1.5) $baseline ($baseline*1.5);
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
span {
.item-metadata, .item-actions {
display: inline-block;
vertical-align: middle;
}
.user-name {
margin-right: 10px;
font-size: 24px;
font-weight: 300;
// ELEM: item - flag
.flag-role {
@extend .ui-badge;
color: $white;
.msg-you {
margin-left: ($baseline/5);
text-transform: none;
font-weight: 500;
color: $pink-l3;
}
&:after {
border-bottom-color: $pink-d4;
}
&.flag-role-staff {
background: $pink-u3;
}
&.flag-role-admin {
background: $pink;
}
}
.user-email {
font-size: 14px;
font-style: italic;
color: $mediumGrey;
// ELEM: item - metadata
.item-metadata {
width: flex-grid(5, 9);
margin-right: flex-gutter();
.user-username, .user-email {
display: inline-block;
vertical-align: middle;
}
.user-username {
@extend .t-title4;
@include transition(color $tmg-f2 ease-in-out 0s);
margin: 0 ($baseline/2) ($baseline/10) 0;
color: $gray-d4;
font-weight: 600;
}
.user-email {
@extend .t-title6;
}
}
// ELEM: item - actions
.item-actions {
top: 24px;
width: flex-grid(4, 9);
position: static; // nasty reset needed due to base.scss
text-align: right;
.action {
display: inline-block;
vertical-align: middle;
}
.action-role {
width: flex-grid(3, 4);
margin-right: flex-gutter();
}
.action-delete {
width: flex-grid(1, 4);
// STATE: disabled
&.is-disabled {
opacity: 0.0;
visibility: hidden;
pointer-events: none;
}
}
.delete {
@extend .ui-btn-non;
}
// HACK: nasty reset needed due to base.scss
.delete-button {
margin-right: 0;
float: none;
color: inherit;
}
// ELEM: admin role controls
.toggle-admin-role {
&.add-admin-role {
@include blue-button;
@extend .t-action2;
@include transition(all .15s);
display: inline-block;
padding: ($baseline/5) $baseline;
font-weight: 600;
}
&.remove-admin-role {
@include grey-button;
@extend .t-action2;
@include transition(all .15s);
display: inline-block;
padding: ($baseline/5) $baseline;
font-weight: 600;
}
}
.notoggleforyou {
@extend .t-copy-sub1;
color: $gray-l2;
}
}
// STATE: hover
&:hover {
.user-username {
}
.user-email {
}
.item-actions {
}
}
}
}
}
\ No newline at end of file
}
<%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %>
<%! from auth.authz import is_user_in_course_group_role %>
<%inherit file="base.html" />
<%block name="title">${_("Course Team Settings")}</%block>
<%block name="bodyclass">is-signedin course users settings team</%block>
<%block name="bodyclass">is-signedin course users team</%block>
<%block name="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Course Settings")}</small>
<small class="subtitle">${_("Settings")}</small>
<span class="sr">&gt; </span>${_("Course Team")}
</h1>
......@@ -17,7 +19,7 @@
<ul>
%if allow_actions:
<li class="nav-item">
<a href="#" class="button new-button new-user-button"><i class="icon-plus"></i> ${_("New User")}</a>
<a href="#" class="button new-button create-user-button"><i class="icon-plus"></i> ${_("New Team Member")}</a>
</li>
%endif
</ul>
......@@ -25,111 +27,289 @@
</header>
</div>
<div class="main-wrapper">
<div class="inner-wrapper">
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
%if allow_actions:
<div class="wrapper-create-element animate wrapper-create-user">
<form class="create-user" id="create-user-form" name="create-user-form">
<div class="wrapper-form">
<h3 class="title">${_("Add a User to Your Course's Team")}</h3>
<div class="details">
<p>${_("The following list of users have been designated as course staff. This means that these users will have permissions to modify course content. You may add additional course staff below, if you are the course instructor. Please note that they must have already registered and verified their account.")}</p>
</div>
<fieldset class="form-fields">
<legend class="sr">${_("New Team Member Information")}</legend>
<ol class="list-input">
<li class="field text required create-user-email">
<label for="user-email-input">${_("User's Email Address")}</label>
<input id="user-email-input" name="user-email" type="text" placeholder="${_('e.g. jane.doe@gmail.com')}" value="">
<span class="tip tip-stacked">${_("Please provide the email address of the course staff member you'd like to add")}</span>
</li>
</ol>
</fieldset>
</div>
<div class="actions">
<button class="action action-primary" type="submit">${_("Add User")}</button>
<button class="action action-secondary action-cancel">${_("Cancel")}</button>
</div>
</form>
</div>
%endif
<article class="user-overview">
%if allow_actions:
<form class="new-user-form">
<div id="result"></div>
<div class="form-elements">
<label>email: </label><input type="text" id="email" class="email-input" autocomplete="off" placeholder="email@example.com">
<input type="submit" value="Add User" id="add_user" class="add-button" />
<input type="button" value="Cancel" class="cancel-button" />
<ol class="user-list">
% for user in staff:
<% api_url = reverse('course_team_user', kwargs=dict(
org=context_course.location.org,
course=context_course.location.course,
name=context_course.location.name,
email=user.email,
))
%>
<li class="user-item" data-email="${user.email}" data-url="${api_url}">
<% is_instuctor = is_user_in_course_group_role(user, context_course.location, 'instructor', check_staff=False) %>
% if is_instuctor:
<span class="wrapper-ui-badge">
<span class="flag flag-role flag-role-admin is-hanging">
<span class="label sr">${_("Current Role:")}</span>
<span class="value">
${_("Admin")}
% if request.user.id == user.id:
<span class="msg-you">${_("You!")}</span>
% endif
</span>
</span>
</span>
% else:
<span class="wrapper-ui-badge">
<span class="flag flag-role flag-role-staff is-hanging">
<span class="label sr">${_("Current Role:")}</span>
<span class="value">
${_("Staff")}
% if request.user.id == user.id:
<span class="msg-you">${_("You!")}</span>
% endif
</span>
</span>
</span>
% endif
<div class="item-metadata">
<h3 class="user-name">
<span class="user-username">${user.username}</span>
<span class="user-email">
<a class="action action-email" href="mailto:${user.email}" title="${_("send an email message to {email}").format(email=user.email)}">${user.email}</a>
</span>
</h3>
</div>
% if allow_actions:
<ul class="item-actions user-actions">
<li class="action action-role">
% if is_instuctor and len(instructors) == 1:
<span class="admin-role notoggleforyou">${_("Promote another member to Admin to remove your admin rights")}</span>
% else:
<a href="#" class="admin-role toggle-admin-role ${'remove' if is_instuctor else 'add'}-admin-role">${_("Remove Admin Access") if is_instuctor else _("Add Admin Access")}</a>
% endif
</li>
<li class="action action-delete ${"is-disabled" if request.user.id == user.id else ""}">
<a href="#" class="delete remove-user action-icon" data-id="${user.email}"><i class="icon-trash"></i><span class="sr">${_("Delete the user, {username}").format(username=user.username)}</span></a>
</li>
</ul>
% endif
</li>
% endfor
</ol>
<% user_is_instuctor = is_user_in_course_group_role(request.user, context_course.location, 'instructor', check_staff=False) %>
% if user_is_instuctor and len(staff) == 1:
<div class="notice notice-incontext notice-create has-actions">
<div class="msg">
<h3 class="title">${_('Add Team Members to This Course')}</h3>
<div class="copy">
<p>${_('Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account. ')}</p>
</div>
</div>
</form>
%endif
<div>
<ol class="user-list">
% for user in staff:
<li>
<span class="user-name">${user.username}</span>
<span class="user-email">${user.email}</span>
%if allow_actions :
<div class="item-actions">
%if request_user_id != user.id:
<a href="#" class="delete-button remove-user" data-id="${user.email}"><span class="delete-icon"></span></a>
%endif
</div>
%endif
<ul class="list-actions">
<li class="action-item">
<a href="#" class="action action-primary button new-button create-user-button"><i class="icon-plus icon-inline"></i> ${_('Add a New Team Member')}</a>
</li>
% endfor
</ol>
</ul>
</div>
%endif
</article>
</div>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title-3">${_("About Roles within Your Course Team")}</h3>
<p>${_("Course team members are co-authors (staff). They have full access to all the content in the course and all the same editing privileges. Admins have the unique ability to add and remove course team members.")}</p>
</div>
% if user_is_instuctor and len(instructors) == 1:
<div class="bit">
<h3 class="title-3">${_("Tranferring Ownership")}</h3>
<p>${_("There must always be an Admin assigned to every course. To transfer your ownership of the course, add Admin access to another user and request they remove you from the Course Team list.")}</p>
</div>
% endif
</aside>
</section>
</div>
</%block>
<%block name="jsextra">
<script type="text/javascript">
var $newUserForm;
var addUserPostbackUrl = "${add_user_postback_url}";
var removeUserPostbackUrl = "${remove_user_postback_url}";
function showNewUserForm(e) {
e.preventDefault();
$newUserForm.slideDown(150);
$newUserForm.find('.email-input').focus();
}
function hideNewUserForm(e) {
e.preventDefault();
$newUserForm.slideUp(150);
$('#result').hide();
$('#email').val('');
}
function checkForCancel(e) {
if(e.which == 27) {
e.data.$cancelButton.click();
}
}
function addUser(e) {
e.preventDefault();
$.ajax({
url: addUserPostbackUrl,
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({ 'email': $('#email').val()}),
success: function(data) {
location.reload();
},
notifyOnError: false,
error: function(jqXHR, textStatus, errorThrown) {
data = JSON.parse(jqXHR.responseText);
$('#result').show().empty().append(data.ErrMsg);
}
});
}
var tplUserURL = "${reverse('course_team_user', kwargs=dict(
org=context_course.location.org,
course=context_course.location.course,
name=context_course.location.name,
email="@@EMAIL@@",
))}"
$(document).ready(function() {
$newUserForm = $('.new-user-form');
var $cancelButton = $newUserForm.find('.cancel-button');
$newUserForm.bind('submit', addUser);
$cancelButton.bind('click', hideNewUserForm);
var $createUserForm = $('#create-user-form');
var $createUserFormWrapper = $createUserForm.closest('.wrapper-create-user');
$createUserForm.bind('submit', function(e) {
e.preventDefault();
var url = tplUserURL.replace("@@EMAIL@@", $('#user-email-input').val().trim())
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({
role: 'staff',
}),
success: function(data) {
location.reload();
},
notifyOnError: false,
error: function(jqXHR, textStatus, errorThrown) {
var message;
try {
message = JSON.parse(jqXHR.responseText).error || "Unknown";
} catch (e) {
message = "Unknown";
}
var prompt = new CMS.Views.Prompt.Error({
title: gettext("Error adding user"),
message: message,
actions: {
primary: {
text: gettext("OK"),
click: function(view) {
view.hide();
$("#user-email-input").focus()
}
}
}
})
prompt.show();
}
});
});
$('.new-user-button').bind('click', showNewUserForm);
$('body').bind('keyup', { $cancelButton: $cancelButton }, checkForCancel);
var $cancelButton = $createUserForm.find('.action-cancel');
$cancelButton.bind('click', function(e) {
e.preventDefault();
$('.create-user-button').toggleClass('is-disabled');
$createUserFormWrapper.toggleClass('is-shown');
$('#user-email-input').val('');
});
$('.create-user-button').bind('click', function(e) {
e.preventDefault();
$('.create-user-button').toggleClass('is-disabled');
$createUserFormWrapper.toggleClass('is-shown');
$createUserForm.find('#user-email-input').focus();
});
$('body').bind('keyup', function(e) {
if(e.which == 27) {
$cancelButton.click();
}
});
$('.remove-user').click(function() {
var url = tplUserURL.replace("@@EMAIL@@", $(this).data('id'))
$.ajax({
url: removeUserPostbackUrl,
type: 'POST',
url: url,
type: 'DELETE',
dataType: 'json',
contentType: 'application/json',
data:JSON.stringify({ 'email': $(this).data('id')}),
}).done(function() {
success: function(data) {
location.reload();
})
},
notifyOnError: false,
error: function(jqXHR, textStatus, errorThrown) {
var message;
try {
message = JSON.parse(jqXHR.responseText).error || "Unknown";
} catch (e) {
message = "Unknown";
}
var prompt = new CMS.Views.Prompt.Error({
title: gettext("Error removing user"),
message: message,
actions: {
primary: {
text: gettext("OK"),
click: function(view) {
view.hide();
}
}
}
})
prompt.show();
}
});
});
$(".toggle-admin-role").click(function(e) {
e.preventDefault()
var type;
if($(this).hasClass("add-admin-role")) {
role = 'instructor';
} else {
role = 'staff';
}
var url = $(this).closest("li[data-url]").data("url");
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({
role: role
}),
success: function(data) {
location.reload();
},
notifyOnError: false,
error: function(jqXHR, textStatus, errorThrown) {
var message;
try {
message = JSON.parse(jqXHR.responseText).error || "Unknown";
} catch (e) {
message = "Unknown";
}
var prompt = new CMS.Views.Prompt.Error({
title: gettext("Error changing user"),
message: message,
actions: {
primary: {
text: gettext("OK"),
click: function(view) {
view.hide();
}
}
}
})
prompt.show();
}
})
})
});
</script>
</%block>
......@@ -262,7 +262,7 @@ from contentstore import utils
<nav class="nav-related">
<ul>
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li>
</ul>
</nav>
......
......@@ -98,7 +98,7 @@ editor.render();
<ul>
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('manage_users', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Course Team")}</a></li>
</ul>
</nav>
% endif
......
......@@ -140,7 +140,7 @@ from contentstore import utils
<nav class="nav-related">
<ul>
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('manage_users', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li>
</ul>
</nav>
......
......@@ -60,7 +60,7 @@
<a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a>
</li>
<li class="nav-item nav-course-settings-team">
<a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">${_("Course Team")}</a>
<a href="${reverse('manage_users', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Course Team")}</a>
</li>
<li class="nav-item nav-course-settings-advanced">
<a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a>
......
......@@ -40,14 +40,12 @@ urlpatterns = ('', # nopep8
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
'contentstore.views.upload_asset', name='upload_asset'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/team/(?P<name>[^/]+)$',
'contentstore.views.manage_users', name='manage_users'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/team/(?P<name>[^/]+)/(?P<email>[^/]+)$',
'contentstore.views.course_team_user', name='course_team_user'),
url(r'^manage_users/(?P<location>.*?)$', 'contentstore.views.manage_users', name='manage_users'),
url(r'^add_user/(?P<location>.*?)$',
'contentstore.views.add_user', name='add_user'),
url(r'^remove_user/(?P<location>.*?)$',
'contentstore.views.remove_user', name='remove_user'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<name>[^/]+)/remove_user$',
'contentstore.views.remove_user', name='remove_user'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/info/(?P<name>[^/]+)$',
'contentstore.views.course_info', name='course_info'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$',
......
......@@ -205,7 +205,7 @@
// extends - UI archetypes - well
.ui-well {
box-shadow: inset 0 1px 2px 1px $shadow;
padding: ($baseline*0.75);
padding: ($baseline*0.75) $baseline;
}
// ====================
......
......@@ -19,6 +19,8 @@ def find_full_path(path_to_file):
def main(argv):
parser = argparse.ArgumentParser(description="Run just one test")
parser.add_argument('--nocapture', '-s', action='store_true', help="Don't capture stdout (any stdout output will be printed immediately)")
parser.add_argument('--pdb', action='store_true', help="Use pdb for test errors")
parser.add_argument('--pdb-fail', action='store_true', help="Use pdb for test failures")
parser.add_argument('words', metavar="WORDS", nargs='+', help="The description of a test failure, like 'ERROR: test_set_missing_field (courseware.tests.test_model_data.TestStudentModuleStorage)'")
args = parser.parse_args(argv)
......@@ -54,6 +56,10 @@ def main(argv):
django_args = ["./manage.py", system, "--settings", "test", "test"]
if args.nocapture:
django_args.append("-s")
if args.pdb:
django_args.append("--pdb")
if args.pdb_fail:
django_args.append("--pdb-fail")
django_args.append(test_spec)
print " ".join(django_args)
......
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