diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 51a98f2de770..66b0bc6cb1db 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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. diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 0f2e60dd6e8f..4923851445a5 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -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 diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index d357c8ae96c4..648ca680b9de 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -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', diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 2b206e4466a8..8a8f6deb04ce 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -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): diff --git a/cms/djangoapps/contentstore/features/course-team.feature b/cms/djangoapps/contentstore/features/course-team.feature index fc1212f398b4..ecce174ca258 100644 --- a/cms/djangoapps/contentstore/features/course-team.feature +++ b/cms/djangoapps/contentstore/features/course-team.feature @@ -1,7 +1,7 @@ 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 diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py index ad5d31977c57..18456b15f783 100644 --- a/cms/djangoapps/contentstore/features/course-team.py +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -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) diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index 02999f65671f..6f8f102df820 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -1,5 +1,5 @@ """ 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 @@ def compare_checklists(self, persisted, request): 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 diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index a51110163d31..0ba4c49874bd 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1167,7 +1167,9 @@ def test_cms_imported_course_walkthrough(self): # 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 diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index 8fea4004dd7f..4b9dcf487f98 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -1,195 +1,319 @@ -""" -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) diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index fec82db1bb78..26c49843b5e0 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -72,50 +72,6 @@ def lms_link_test(self): ) -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. """ diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 4973bddaca0f..a2e927ef46f4 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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. diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 0bb9551ac950..6d371bef189d 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -29,7 +29,6 @@ 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, + }) }) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index bcf4a1a5b97d..74f0a3376981 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -4,12 +4,13 @@ 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 diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 3a1844811890..a0db8ecef8ed 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -1,7 +1,10 @@ +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 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 course_filter(course): 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): diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index 9907b0599525..55048b49dcce 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -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 diff --git a/cms/static/sass/elements/_icons.scss b/cms/static/sass/elements/_icons.scss index a75c97ea76d0..9bbb72d67e8a 100644 --- a/cms/static/sass/elements/_icons.scss +++ b/cms/static/sass/elements/_icons.scss @@ -1,4 +1,4 @@ -// 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; + } + } +} diff --git a/cms/static/sass/elements/_system-help.scss b/cms/static/sass/elements/_system-help.scss index 3b33946e19de..c5fcc6a0ec3f 100644 --- a/cms/static/sass/elements/_system-help.scss +++ b/cms/static/sass/elements/_system-help.scss @@ -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; diff --git a/cms/static/sass/views/_textbooks.scss b/cms/static/sass/views/_textbooks.scss index 8d2b2d9489b7..8058673b2be1 100644 --- a/cms/static/sass/views/_textbooks.scss +++ b/cms/static/sass/views/_textbooks.scss @@ -30,7 +30,7 @@ body.course.textbooks { } .textbook { - @extend .window; + @extend .ui-window; position: relative; .view-textbook { diff --git a/cms/static/sass/views/_users.scss b/cms/static/sass/views/_users.scss index ecaa31970783..7e88edd38c87 100644 --- a/cms/static/sass/views/_users.scss +++ b/cms/static/sass/views/_users.scss @@ -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 +} diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 22d57be41d2c..6256d2533363 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -1,14 +1,16 @@ <%! 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">