diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 68308980ad..51a98f2de7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Studio: Send e-mails to new Studio users (on edge only) when their course creator +status has changed. This will not be in use until the course creator table +is enabled. + LMS: Added user preferences (arbitrary user/key/value tuples, for which which user/key is unique) and a REST API for reading users and preferences. Access to the REST API is restricted by use of the diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index cdba6d9cb2..18e179abdb 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,7 +2,7 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true +from nose.tools import assert_false, assert_equal, assert_regexp_matches from common import type_in_codemirror, press_the_notification_button KEY_CSS = '.key input.policy-key' @@ -90,18 +90,18 @@ def the_policy_key_value_is_changed(step): ############# HELPERS ############### def assert_policy_entries(expected_keys, expected_values): - for counter in range(len(expected_keys)): - index = get_index_of(expected_keys[counter]) - assert_false(index == -1, "Could not find key: " + expected_keys[counter]) - assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect") + for key, value in zip(expected_keys, expected_values): + index = get_index_of(key) + assert_false(index == -1, "Could not find key: {key}".format(key=key)) + assert_equal(value, world.css_find(VALUE_CSS)[index].value, "value is incorrect") def get_index_of(expected_key): - for counter in range(len(world.css_find(KEY_CSS))): - # Sometimes get stale reference if I hold on to the array of elements - key = world.css_value(KEY_CSS, index=counter) + for i, element in enumerate(world.css_find(KEY_CSS)): + # Sometimes get stale reference if I hold on to the array of elements + key = world.css_value(KEY_CSS, index=i) if key == expected_key: - return counter + return i return -1 diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index 13927a7d89..15a7c4b9ab 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -19,7 +19,7 @@ def i_see_only_the_settings_and_values(step): world.verify_all_setting_entries( [ ['Category', "Week 1", False], - ['Display Name', "Discussion Tag", False], + ['Display Name', "Discussion", False], ['Subcategory', "Topic-Level Student-Visible Label", False] ]) diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py index b03388c89a..c3e0afa480 100644 --- a/cms/djangoapps/contentstore/features/html-editor.py +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -14,4 +14,4 @@ def i_created_blank_html_page(step): @step('I see only the HTML display name setting$') def i_see_only_the_html_display_name(step): - world.verify_all_setting_entries([['Display Name', "Blank HTML Page", False]]) + world.verify_all_setting_entries([['Display Name', "Text", False]]) diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature index 03a1c9524a..9706cfe7ea 100644 --- a/cms/djangoapps/contentstore/features/signup.feature +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -9,4 +9,4 @@ Feature: Sign in And I fill in the registration form And I press the Create My Account button on the registration form Then I should see be on the studio home page - And I should see the message "please click on the activation link in your email." + And I should see the message "complete your sign up we need you to verify your email address" diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 49a305f70b..8957666b06 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -2,7 +2,6 @@ #pylint: disable=W0621 from lettuce import world, step -from common import * @step('I fill in the registration form$') @@ -25,7 +24,7 @@ def i_press_the_button_on_the_registration_form(step): @step('I should see be on the studio home page$') def i_should_see_be_on_the_studio_home_page(step): - assert world.browser.find_by_css('div.inner-wrapper') + step.given('I should see the message "My Courses"') @step(u'I should see the message "([^"]*)"$') diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index e0f76b30ad..93d638e621 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -7,7 +7,7 @@ from lettuce import world, step @step('I see the correct settings and default values$') def i_see_the_correct_settings_and_values(step): world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False], - ['Display Name', 'Video Title', False], + ['Display Name', 'Video', False], ['Download Track', '', False], ['Download Video', '', False], ['Show Captions', 'True', False], diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index f945ef50fc..8fea4004dd 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -1,6 +1,18 @@ +""" +Tests for user.py. +""" import json +import mock from .utils import CourseTestCase 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 class UsersTestCase(CourseTestCase): @@ -13,3 +25,171 @@ class UsersTestCase(CourseTestCase): 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. + """ + 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.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite()) + + self.table_entry.state = CourseCreator.DENIED + self.creator_admin.save_model(self.deny_request, self.table_entry, None, True) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 020e4b5cb9..3a18448118 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -3,15 +3,17 @@ from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.contrib.auth.decorators import login_required from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_POST from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response +from django.core.context_processors import csrf -from xmodule.modulestore import Location 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 course_creators.views import get_course_creator_status, add_user_with_status_unrequested, user_requested_access from .access import has_access @@ -40,10 +42,22 @@ def index(request): get_lms_link_for_item(course.location, course_id=course.location.course_id)) for course in courses], 'user': request.user, - 'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff + 'request_course_creator_url': reverse('request_course_creator'), + 'course_creator_status': _get_course_creator_status(request.user), + 'csrf': csrf(request)['csrf_token'] }) +@require_POST +@login_required +def request_course_creator(request): + """ + User has requested course creation access. + """ + user_requested_access(request.user) + return JsonResponse({"Status": "OK"}) + + @login_required @ensure_csrf_cookie def manage_users(request, location): @@ -144,3 +158,28 @@ def remove_user(request, location): remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME) return JsonResponse({"Status": "OK"}) + + +def _get_course_creator_status(user): + """ + Helper method for returning the course creator status for a particular user, + taking into account the values of DISABLE_COURSE_CREATION and ENABLE_CREATOR_GROUP. + + If the user passed in has not previously visited the index page, it will be + added with status 'unrequested' if the course creator group is in use. + """ + if user.is_staff: + course_creator_status = 'granted' + elif settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False): + course_creator_status = 'disallowed_for_this_site' + elif settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False): + course_creator_status = get_course_creator_status(user) + if course_creator_status is None: + # User not grandfathered in as an existing user, has not previously visited the dashboard page. + # Add the user to the course creator admin table with status 'unrequested'. + add_user_with_status_unrequested(user) + course_creator_status = get_course_creator_status(user) + else: + course_creator_status = 'granted' + + return course_creator_status diff --git a/cms/djangoapps/course_creators/admin.py b/cms/djangoapps/course_creators/admin.py index 7518946270..65473d8bde 100644 --- a/cms/djangoapps/course_creators/admin.py +++ b/cms/djangoapps/course_creators/admin.py @@ -6,7 +6,13 @@ from course_creators.models import CourseCreator, update_creator_state from course_creators.views import update_course_creator_group from django.contrib import admin +from django.conf import settings from django.dispatch import receiver +from mitxmako.shortcuts import render_to_string + +import logging + +log = logging.getLogger("studio.coursecreatoradmin") def get_email(obj): @@ -60,4 +66,25 @@ def update_creator_group_callback(sender, **kwargs): """ Callback for when the model's creator status has changed. """ - update_course_creator_group(kwargs['caller'], kwargs['user'], kwargs['add']) + user = kwargs['user'] + updated_state = kwargs['state'] + update_course_creator_group(kwargs['caller'], user, updated_state == CourseCreator.GRANTED) + + studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL','') + context = {'studio_request_email': studio_request_email} + + subject = render_to_string('emails/course_creator_subject.txt', context) + subject = ''.join(subject.splitlines()) + if updated_state == CourseCreator.GRANTED: + message_template = 'emails/course_creator_granted.txt' + elif updated_state == CourseCreator.DENIED: + message_template = 'emails/course_creator_denied.txt' + else: + # changed to unrequested or pending + message_template = 'emails/course_creator_revoked.txt' + message = render_to_string(message_template, context) + + try: + user.email_user(subject, message, studio_request_email) + except: + log.warning("Unable to send course creator status e-mail to %s", user.email) diff --git a/cms/djangoapps/course_creators/models.py b/cms/djangoapps/course_creators/models.py index 607dae4af2..ba434c9140 100644 --- a/cms/djangoapps/course_creators/models.py +++ b/cms/djangoapps/course_creators/models.py @@ -39,7 +39,7 @@ class CourseCreator(models.Model): "why course creation access was denied)")) def __unicode__(self): - return u'%str | %str [%str] | %str' % (self.user, self.state, self.state_changed, self.note) + return u"{0} | {1} [{2}]".format(self.user, self.state, self.state_changed) @receiver(post_init, sender=CourseCreator) @@ -54,18 +54,23 @@ def post_init_callback(sender, **kwargs): @receiver(post_save, sender=CourseCreator) def post_save_callback(sender, **kwargs): """ - Extend to update state_changed time and modify the course creator group in authz.py. + Extend to update state_changed time and fire event to update course creator group, if appropriate. """ instance = kwargs['instance'] # We only wish to modify the state_changed time if the state has been modified. We don't wish to # modify it for changes to the notes field. if instance.state != instance.orig_state: - update_creator_state.send( - sender=sender, - caller=instance.admin, - user=instance.user, - add=instance.state == CourseCreator.GRANTED - ) + # If either old or new state is 'granted', we must manipulate the course creator + # group maintained by authz. That requires staff permissions (stored admin). + if instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED: + assert hasattr(instance, 'admin'), 'Must have stored staff user to change course creator group' + update_creator_state.send( + sender=sender, + caller=instance.admin, + user=instance.user, + state=instance.state + ) + instance.state_changed = timezone.now() instance.orig_state = instance.state instance.save() diff --git a/cms/djangoapps/course_creators/tests/test_admin.py b/cms/djangoapps/course_creators/tests/test_admin.py index 6ef48746e7..91a28d77ae 100644 --- a/cms/djangoapps/course_creators/tests/test_admin.py +++ b/cms/djangoapps/course_creators/tests/test_admin.py @@ -13,6 +13,11 @@ from course_creators.models import CourseCreator from auth.authz import is_user_in_creator_group +def mock_render_to_string(template_name, context): + """Return a string that encodes template_name and context""" + return str((template_name, context)) + + class CourseCreatorAdminTest(TestCase): """ Tests for course creator admin. @@ -32,17 +37,40 @@ class CourseCreatorAdminTest(TestCase): self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite()) - def test_change_status(self): + @mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True)) + @mock.patch('django.contrib.auth.models.User.email_user') + def test_change_status(self, email_user): """ - Tests that updates to state impact the creator group maintained in authz.py. + Tests that updates to state impact the creator group maintained in authz.py and that e-mails are sent. """ + STUDIO_REQUEST_EMAIL = 'mark@marky.mark' + def change_state(state, is_creator): """ Helper method for changing state """ self.table_entry.state = state self.creator_admin.save_model(self.request, self.table_entry, None, True) self.assertEqual(is_creator, is_user_in_creator_group(self.user)) + + context = {'studio_request_email': STUDIO_REQUEST_EMAIL} + if state == CourseCreator.GRANTED: + template = 'emails/course_creator_granted.txt' + elif state == CourseCreator.DENIED: + template = 'emails/course_creator_denied.txt' + else: + template = 'emails/course_creator_revoked.txt' + email_user.assert_called_with( + mock_render_to_string('emails/course_creator_subject.txt', context), + mock_render_to_string(template, context), + STUDIO_REQUEST_EMAIL + ) + + with mock.patch.dict( + 'django.conf.settings.MITX_FEATURES', + { + "ENABLE_CREATOR_GROUP": True, + "STUDIO_REQUEST_EMAIL": STUDIO_REQUEST_EMAIL + }): - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): # User is initially unrequested. self.assertFalse(is_user_in_creator_group(self.user)) diff --git a/cms/djangoapps/course_creators/tests/test_views.py b/cms/djangoapps/course_creators/tests/test_views.py index bd91208b9c..95c50ffb76 100644 --- a/cms/djangoapps/course_creators/tests/test_views.py +++ b/cms/djangoapps/course_creators/tests/test_views.py @@ -7,7 +7,7 @@ from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied from course_creators.views import add_user_with_status_unrequested, add_user_with_status_granted -from course_creators.views import get_course_creator_status, update_course_creator_group +from course_creators.views import get_course_creator_status, update_course_creator_group, user_requested_access from course_creators.models import CourseCreator from auth.authz import is_user_in_creator_group import mock @@ -26,14 +26,11 @@ class CourseCreatorView(TestCase): def test_staff_permission_required(self): """ - Tests that add methods and course creator group method must be called with staff permissions. + Tests that any method changing the course creator authz group must be called with staff permissions. """ with self.assertRaises(PermissionDenied): add_user_with_status_granted(self.user, self.user) - with self.assertRaises(PermissionDenied): - add_user_with_status_unrequested(self.user, self.user) - with self.assertRaises(PermissionDenied): update_course_creator_group(self.user, self.user, True) @@ -41,7 +38,7 @@ class CourseCreatorView(TestCase): self.assertIsNone(get_course_creator_status(self.user)) def test_add_unrequested(self): - add_user_with_status_unrequested(self.admin, self.user) + add_user_with_status_unrequested(self.user) self.assertEqual('unrequested', get_course_creator_status(self.user)) # Calling add again will be a no-op (even if state is different). @@ -57,7 +54,7 @@ class CourseCreatorView(TestCase): self.assertEqual('granted', get_course_creator_status(self.user)) # Calling add again will be a no-op (even if state is different). - add_user_with_status_unrequested(self.admin, self.user) + add_user_with_status_unrequested(self.user) self.assertEqual('granted', get_course_creator_status(self.user)) self.assertTrue(is_user_in_creator_group(self.user)) @@ -69,3 +66,27 @@ class CourseCreatorView(TestCase): self.assertTrue(is_user_in_creator_group(self.user)) update_course_creator_group(self.admin, self.user, False) self.assertFalse(is_user_in_creator_group(self.user)) + + def test_user_requested_access(self): + add_user_with_status_unrequested(self.user) + self.assertEqual('unrequested', get_course_creator_status(self.user)) + user_requested_access(self.user) + self.assertEqual('pending', get_course_creator_status(self.user)) + + def test_user_requested_already_granted(self): + add_user_with_status_granted(self.admin, self.user) + self.assertEqual('granted', get_course_creator_status(self.user)) + # Will not "downgrade" to pending because that would require removing the + # user from the authz course creator group (and that can only be done by an admin). + user_requested_access(self.user) + self.assertEqual('granted', get_course_creator_status(self.user)) + + def test_add_user_unrequested_staff(self): + # Users marked as is_staff will not be added to the course creator table. + add_user_with_status_unrequested(self.admin) + self.assertIsNone(get_course_creator_status(self.admin)) + + def test_add_user_granted_staff(self): + # Users marked as is_staff will not be added to the course creator table. + add_user_with_status_granted(self.admin, self.admin) + self.assertIsNone(get_course_creator_status(self.admin)) diff --git a/cms/djangoapps/course_creators/views.py b/cms/djangoapps/course_creators/views.py index 902406e620..e9b38ed169 100644 --- a/cms/djangoapps/course_creators/views.py +++ b/cms/djangoapps/course_creators/views.py @@ -2,32 +2,38 @@ Methods for interacting programmatically with the user creator table. """ from course_creators.models import CourseCreator -from django.core.exceptions import PermissionDenied from auth.authz import add_user_to_creator_group, remove_user_from_creator_group -def add_user_with_status_unrequested(caller, user): +def add_user_with_status_unrequested(user): """ Adds a user to the course creator table with status 'unrequested'. If the user is already in the table, this method is a no-op - (state will not be changed). Caller must have staff permissions. + (state will not be changed). + + If the user is marked as is_staff, this method is a no-op (user + will not be added to table). """ - _add_user(caller, user, CourseCreator.UNREQUESTED) + _add_user(user, CourseCreator.UNREQUESTED) def add_user_with_status_granted(caller, user): """ Adds a user to the course creator table with status 'granted'. - If the user is already in the table, this method is a no-op - (state will not be changed). Caller must have staff permissions. + If appropriate, this method also adds the user to the course creator group maintained by authz.py. + Caller must have staff permissions. - This method also adds the user to the course creator group maintained by authz.py. + If the user is already in the table, this method is a no-op + (state will not be changed). + + If the user is marked as is_staff, this method is a no-op (user + will not be added to table, nor added to authz.py group). """ - _add_user(caller, user, CourseCreator.GRANTED) - update_course_creator_group(caller, user, True) + if _add_user(user, CourseCreator.GRANTED): + update_course_creator_group(caller, user, True) def update_course_creator_group(caller, user, add): @@ -61,16 +67,33 @@ def get_course_creator_status(user): return user[0].state -def _add_user(caller, user, state): +def user_requested_access(user): + """ + User has requested course creator access. + + This changes the user state to CourseCreator.PENDING, unless the user + state is already CourseCreator.GRANTED, in which case this method is a no-op. + """ + user = CourseCreator.objects.get(user=user) + if user.state != CourseCreator.GRANTED: + user.state = CourseCreator.PENDING + user.save() + + +def _add_user(user, state): """ Adds a user to the course creator table with the specified state. - If the user is already in the table, this method is a no-op - (state will not be changed). - """ - if not caller.is_active or not caller.is_authenticated or not caller.is_staff: - raise PermissionDenied + Returns True if user was added to table, else False. - if CourseCreator.objects.filter(user=user).count() == 0: + If the user is already in the table, this method is a no-op + (state will not be changed, method will return False). + + If the user is marked as is_staff, this method is a no-op (False will be returned). + """ + if not user.is_staff and CourseCreator.objects.filter(user=user).count() == 0: entry = CourseCreator(user=user, state=state) entry.save() + return True + + return False diff --git a/cms/envs/common.py b/cms/envs/common.py index 260aa30cd2..f5baa3211b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -42,8 +42,8 @@ MITX_FEATURES = { # do not display video when running automated acceptance tests 'STUB_VIDEO_FOR_TESTING': False, - # email address for staff (eg to request course creation) - 'STAFF_EMAIL': '', + # email address for studio staff (eg to request course creation) + 'STUDIO_REQUEST_EMAIL': '', 'STUDIO_NPS_SURVEY': True, @@ -62,9 +62,6 @@ MITX_FEATURES = { } ENABLE_JASMINE = False -# needed to use lms student app -GENERATE_RANDOM_USER_CREDENTIALS = False - ############################# SET PATH INFORMATION ############################# PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /mitx/cms @@ -108,9 +105,12 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.core.context_processors.static', 'django.contrib.messages.context_processors.messages', 'django.contrib.auth.context_processors.auth', # this is required for admin - 'django.core.context_processors.csrf', # necessary for csrf protection ) +# add csrf support unless disabled for load testing +if not MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING'): + TEMPLATE_CONTEXT_PROCESSORS += ('django.core.context_processors.csrf',) # necessary for csrf protection + LMS_BASE = None #################### CAPA External Code Evaluation ############################# @@ -142,7 +142,6 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', 'method_override.middleware.MethodOverrideMiddleware', # Instead of AuthenticationMiddleware, we use a cache-backed version @@ -158,6 +157,10 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.transaction.TransactionMiddleware' ) +# add in csrf middleware unless disabled for load testing +if not MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING'): + MIDDLEWARE_CLASSES = MIDDLEWARE_CLASSES + ('django.middleware.csrf.CsrfViewMiddleware',) + ############################ SIGNAL HANDLERS ################################ # This is imported to register the exception signal handling that logs exceptions import monitoring.exceptions # noqa diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 3a30e0bc81..ebd7ed95cb 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -60,12 +60,10 @@ $(document).ready(function() { $('.nav-dd .nav-item .title').removeClass('is-selected'); }); - $('.nav-dd .nav-item .title').click(function(e) { + $('.nav-dd .nav-item').click(function(e) { - $subnav = $(this).parent().find('.wrapper-nav-sub'); - $title = $(this).parent().find('.title'); - e.preventDefault(); - e.stopPropagation(); + $subnav = $(this).find('.wrapper-nav-sub'); + $title = $(this).find('.title'); if ($subnav.hasClass('is-shown')) { $subnav.removeClass('is-shown'); @@ -75,6 +73,9 @@ $(document).ready(function() { $('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown'); $title.addClass('is-selected'); $subnav.addClass('is-shown'); + // if propogation is not stopped, the event will bubble up to the + // body element, which will close the dropdown. + e.stopPropagation(); } }); @@ -596,11 +597,11 @@ function cancelNewSection(e) { function addNewCourse(e) { e.preventDefault(); - - $(e.target).hide(); + $('.new-course-button').addClass('disabled'); + $(e.target).addClass('disabled'); var $newCourse = $($('#new-course-template').html()); var $cancelButton = $newCourse.find('.new-course-cancel'); - $('.inner-wrapper').prepend($newCourse); + $('.courses').prepend($newCourse); $newCourse.find('.new-course-name').focus().select(); $newCourse.find('form').bind('submit', saveNewCourse); $cancelButton.bind('click', cancelNewCourse); @@ -645,7 +646,7 @@ function saveNewCourse(e) { function cancelNewCourse(e) { e.preventDefault(); - $('.new-course-button').show(); + $('.new-course-button').removeClass('disabled'); $(this).parents('section.new-course').remove(); } diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 054a401e4b..e4495d0248 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -368,42 +368,6 @@ p, ul, ol, dl { color: $gray-d3; } } - - .introduction { - @include box-sizing(border-box); - @extend .t-copy-sub1; - width: flex-grid(12); - margin: 0 0 $baseline 0; - - .copy strong { - font-weight: 600; - } - - &.has-links { - @include clearfix(); - - .copy { - float: left; - width: flex-grid(8,12); - margin-right: flex-gutter(); - } - - .nav-introduction-supplementary { - @extend .t-copy-sub2; - float: right; - width: flex-grid(4,12); - display: block; - text-align: right; - - .icon { - @extend .t-action3; - display: inline-block; - vertical-align: middle; - margin-right: ($baseline/4); - } - } - } - } } .content-primary, .content-supplementary { @@ -482,6 +446,24 @@ p, ul, ol, dl { } } + // actions + .list-actions { + @extend .cont-no-list; + + .action-item { + margin-bottom: ($baseline/4); + border-bottom: 1px dotted $gray-l4; + padding-bottom: ($baseline/4); + + + &:last-child { + margin-bottom: 0; + border: none; + padding-bottom: 0; + } + } + } + // navigation .nav-related, .nav-page { diff --git a/cms/static/sass/_shame.scss b/cms/static/sass/_shame.scss index 2a11037007..f9030b72e8 100644 --- a/cms/static/sass/_shame.scss +++ b/cms/static/sass/_shame.scss @@ -2,10 +2,32 @@ // // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/) // ==================== +// view - dashboard +body.dashboard { + + // elements - authorship controls + .wrapper-authorshiprights { + + .ui-toggle-control { + // needed to override general a element transition properties - need to fix. + transition-duration: 0.25s; + transition-timing-function: ease-in-out; + } + + .icon-remove-sign { + // needed to override general a element transition properties - need to fix. + transition-duration: 0.25s; + transition-timing-function: ease-in-out; + } + } +} + -// known things to do (paint the fence, sand the floor, wax on/off) // ==================== + + +// known things to do (paint the fence, sand the floor, wax on/off): + // * centralize and move form styling into forms.scss - cms/static/sass/views/_textbooks.scss and cms/static/sass/views/_settings.scss // * move dialogue styles into cms/static/sass/elements/_modal.scss // * use the @include placeholder Bourbon mixin (http://bourbon.io/docs/#placeholder) for any placeholder styling - diff --git a/cms/static/sass/elements/_system-feedback.scss b/cms/static/sass/elements/_system-feedback.scss index be1b41bf29..74187aca62 100644 --- a/cms/static/sass/elements/_system-feedback.scss +++ b/cms/static/sass/elements/_system-feedback.scss @@ -1,4 +1,46 @@ // studio - elements - system feedback +// ==================== + +// messages +.message { + @extend .t-copy-sub1; + display: block; +} + +.message-status { + display: none; + @include border-top-radius(2px); + @include box-sizing(border-box); + border-bottom: 2px solid $yellow-d2; + margin: 0 0 $baseline 0; + padding: ($baseline/2) $baseline; + font-weight: 500; + background: $yellow-d1; + color: $white; + + [class^="icon-"] { + position: relative; + top: 1px; + @include font-size(16); + display: inline-block; + margin-right: ($baseline/2); + } + + .text { + display: inline-block; + } + + &.error { + border-color: $red-d3; + background: $red-l1; + } + + &.is-shown { + display: block; + } +} + + // alerts, notifications, prompts, and status communication // ==================== diff --git a/cms/static/sass/elements/_system-help.scss b/cms/static/sass/elements/_system-help.scss index d1b0584fc4..3b33946e19 100644 --- a/cms/static/sass/elements/_system-help.scss +++ b/cms/static/sass/elements/_system-help.scss @@ -1,40 +1,185 @@ // studio - elements - system help // ==================== -// notices - in-context: to be used as notices to users within the context of a form/action -.notice-incontext { - @extend .ui-well; - border-radius: ($baseline/10); +// view introductions - common greeting/starting points for the UI +.content .introduction { + @include box-sizing(border-box); + margin-bottom: $baseline; .title { - @extend .t-title7; - margin-bottom: ($baseline/4); + @extend .t-title4; font-weight: 600; } .copy { @extend .t-copy-sub1; - @include transition(opacity $tmg-f2 ease-in-out 0s); - opacity: 0.75; } strong { font-weight: 600; } - &:hover { + // CASE: has links alongside + &.has-links { + @include clearfix(); .copy { - opacity: 1.0; + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .nav-introduction-supplementary { + @extend .t-copy-sub2; + float: right; + width: flex-grid(4,12); + display: block; + text-align: right; + + .icon { + @extend .t-action3; + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } } } } -// particular warnings around a workflow for something +// notices - in-context: to be used as notices to users within the context of a form/action +.notice-incontext { + @extend .ui-well; + border-radius: ($baseline/10); + position: relative; + overflow: hidden; + margin-bottom: $baseline; + + .title { + @extend .t-title7; + margin-bottom: ($baseline/4); + font-weight: 700; + } + + .copy { + @extend .t-copy-sub1; + @include transition(opacity $tmg-f2 ease-in-out 0s); + opacity: 0.75; + margin-bottom: $baseline; + + &:last-child { + margin-bottom: 0; + } + } + + strong { + font-weight: 600; + } + + &.has-status { + + .status-indicator { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: ($baseline/4); + opacity: 0.40; + } + } + + // CASE: notice has actions { + &.has-actions { + + .list-actions { + margin-top: ($baseline*0.75); + + .action-item { + + } + + .action-primary { + @extend .btn-primary-blue; + @extend .t-action3; + } + } + } + + // list of notices all in one + &.list-notices { + + .notice-item { + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l3; + padding-bottom: $baseline; + + &:last-child { + margin-bottom: 0; + border: none; + padding-bottom: 0; + } + } + } +} + +// particular notice - warnings around a workflow for something .notice-workflow { background: $yellow-l5; - .copy { + .status-indicator { + background: $yellow; + } + + title { color: $gray-d1; } + + .copy { + color: $gray; + } +} + +// particular notice - instructional +.notice-instruction { + background-color: $gray-l4; + + .title { + color: $gray-d2; + } + + .copy { + color: $gray-d2; + } + + &.has-actions { + + .list-actions { + + .action-item { + + } + + .action-primary { + @extend .btn-primary-blue; + @extend .t-action3; + } + } + } +} + +// particular notice - confirmation +.notice-confirmation { + background-color: $green-l5; + + .status-indicator { + background: $green-s1; + } + + .title { + color: $green; + } + + .copy { + color: $gray; + } } diff --git a/cms/static/sass/views/_account.scss b/cms/static/sass/views/_account.scss index 53f01eee6d..c2cf139400 100644 --- a/cms/static/sass/views/_account.scss +++ b/cms/static/sass/views/_account.scss @@ -252,44 +252,3 @@ body.signup, body.signin { } } } - -// ==================== - -// messages -.message { - @extend .t-copy-sub1; - display: block; -} - -.message-status { - display: none; - @include border-top-radius(2px); - @include box-sizing(border-box); - border-bottom: 2px solid $yellow-d2; - margin: 0 0 $baseline 0; - padding: ($baseline/2) $baseline; - font-weight: 500; - background: $yellow-d1; - color: $white; - - [class^="icon-"] { - position: relative; - top: 1px; - @include font-size(16); - display: inline-block; - margin-right: ($baseline/2); - } - - .text { - display: inline-block; - } - - &.error { - border-color: shade($red, 50%); - background: tint($red, 20%); - } - - &.is-shown { - display: block; - } -} diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index 8d1b068256..63c8ab36fd 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -3,20 +3,309 @@ body.dashboard { - .my-classes { - margin-top: $baseline; + // temp + .content { + margin-bottom: ($baseline*5); + + &:last-child { + margin-bottom: 0; + } } - .class-list { - margin-top: 20px; - border-radius: 3px; - border: 1px solid $darkGrey; - background: #fff; - box-shadow: 0 1px 2px rgba(0, 0, 0, .1); + // ==================== - li { + // basic layout + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } + + .content-primary { + width: flex-grid(9, 12); + margin-right: flex-gutter(); + } + + .content-supplementary { + width: flex-grid(3, 12); + } + + // ==================== + + // elements - notices + .content .notice-incontext { + width: flexgrid(9, 9); + + // CASE: notice has actions { + &.has-actions, &.list-notices .notice-item.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 { + + } + + .action-create-course { + @extend .btn-primary-green; + @extend .t-action3; + } + } + } + } + + + + // elements - course creation rights controls + .wrapper-creationrights { + overflow: hidden; + + .ui-toggle-control { + @extend .ui-depth2; + @extend .btn-secondary-gray; + @include clearfix(); + display: block; + text-align: left; + + // STATE: hover - syncing up colors with current so transition is smoother + &:hover { + background: $gray-d1; + color: $white; + } + + .label { + @extend .t-action3; + float: left; + width: flex-grid(8, 9); + margin: 3px flex-gutter() 0 0; + } + + .icon-remove-sign { + @extend .t-action1; + @include transform(rotate(45deg)); + @include transform-origin(center center); + @include transition(all $tmg-f1 linear 0s); + float: right; + text-align: right; + } + } + + .ui-toggle-target { + @extend .ui-depth1; + @include transition(opacity $tmg-f1 ease-in-out 0s); position: relative; - border-bottom: 1px solid $mediumGrey; + top: -2px; + display: none; + opacity: 0; + } + + // CASE: when the content area is shown + &.is-shown { + + .ui-toggle-control { + @include border-bottom-radius(0); + + .icon-remove-sign { + @include transform(rotate(90deg)); + @include transform-origin(center center); + } + } + + .ui-toggle-target { + display: block; + opacity: 1.0; + } + } + + + } + + // elements - course creation rights controls + .status-creationrights { + margin-top: $baseline; + + .title { + @extend .t-title7; + margin-bottom: ($baseline/4); + font-weight: 700; + color: $gray-d1; + } + + .copy { + + } + + .list-actions, .form-actions { + margin-top: ($baseline*0.75); + + .action-item { + + } + + .action-primary { + @extend .btn-primary-blue; + @extend .t-action3; + } + + // specific - request button + // BT: should we abstract these states out for all buttons like this + .action-request { + position: relative; + overflow: hidden; + + .icon-cog { + @include transition(all $tmg-f1 ease-in-out $tmg-f1); + @include font-size(20); + position: absolute; + top: ($baseline/2); + left: -($baseline); + visibility: hidden; + opacity: 0.0; + } + + // state: submitting + &.is-submitting { + padding-left: ($baseline*2); + + .icon-cog { + left: ($baseline*0.75); + visibility: visible; + opacity: 1.0; + } + } + + // state: has an error + &.has-error { + padding-left: ($baseline*2); + background: $red; + border-color: $red-d1; + + .icon-cog { + left: ($baseline*0.75); + visibility: visible; + opacity: 1.0; + } + } + } + } + + .status-update { + + .label { + @extend .cont-text-sr; + } + + .value { + border-radius: ($baseline/4); + position: relative; + overflow: hidden; + padding: ($baseline/5) ($baseline/2); + background: $gray; + + .status-indicator { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: ($baseline/4); + opacity: 0.40; + } + } + + .value-formal, .value-description { + border-radius: ($baseline/10); + display: inline-block; + vertical-align: middle; + color: $white; + } + + .value-formal { + @extend .t-title5; + margin: ($baseline/2); + font-weight: 700; + + [class^="icon-"] { + margin-right: ($baseline/4); + } + } + + .value-description { + @extend .t-copy-sub1; + position: relative; + color: $white; + opacity: 0.85; + } + } + + // CASE: rights are not requested yet + &.is-unrequested { + + .title { + @extend .cont-text-sr; + } + } + + // CASE: status is pending + &.is-pending { + + .status-update { + + .value { + background: $orange; + } + + .status-indicator { + background: $orange-d1; + } + } + } + + + // CASE: status is denied + &.is-denied { + + .status-update { + + .value { + background: $red-l1; + } + + .status-indicator { + background: $red-s1; + } + } + } + } + + // ==================== + + // course listings + .courses { + margin: $baseline 0; + } + + .list-courses { + margin-top: $baseline; + border-radius: 3px; + border: 1px solid $gray; + background: $white; + box-shadow: 0 1px 2px $shadow-l1; + + .course-item { + position: relative; + border-bottom: 1px solid $gray-l1; &:last-child { border-bottom: none; @@ -56,7 +345,7 @@ body.dashboard { .view-live-button { z-index: 10000; position: absolute; - top: 15px; + top: ($baseline*0.75); right: $baseline; padding: ($baseline/4) ($baseline/2); opacity: 0.0; @@ -70,17 +359,25 @@ body.dashboard { } .new-course { - padding: 15px 25px; - margin-top: 20px; + @include clearfix(); + padding: ($baseline*0.75) ($baseline*1.25); + margin-top: $baseline; border-radius: 3px; - border: 1px solid $darkGrey; - background: #fff; + border: 1px solid $gray; + background: $white; box-shadow: 0 1px 2px rgba(0, 0, 0, .1); - @include clearfix; + + .title { + @extend .t-title4; + font-weight: 600; + margin-bottom: ($baseline/2); + border-bottom: 1px solid $gray-l3; + padding-bottom: ($baseline/2); + } .row { - margin-bottom: 15px; - @include clearfix; + @include clearfix(); + margin-bottom: ($baseline*0.75); } .column { @@ -97,8 +394,8 @@ body.dashboard { } label { + @extend .t-title7; display: block; - font-size: 13px; font-weight: 700; } @@ -109,7 +406,7 @@ body.dashboard { } .new-course-name { - font-size: 19px; + @extend .t-title5; font-weight: 300; } @@ -120,5 +417,9 @@ body.dashboard { .new-course-cancel { @include white-button; } + + .item-details { + padding-bottom: 0; + } } } diff --git a/cms/templates/activation_active.html b/cms/templates/activation_active.html index 9a4ebd7e4e..d7133062de 100644 --- a/cms/templates/activation_active.html +++ b/cms/templates/activation_active.html @@ -2,14 +2,31 @@ <%inherit file="base.html" /> <%block name="content"> - -
-
- -
-

${_("Account already active!")}

-

${_('This account has already been activated.')}${_("Log in here.")}

+
+
+

${_("Studio Account Activation")}

+
-
+
+
+
+
+ +
+
+

${_("Your account is already active")}

+
+

${_("This account, set up using {0}, has already been activated. Please sign in to start working within edX Studio.".format(user.email))}

+
+
+ + +
+
+
diff --git a/cms/templates/activation_complete.html b/cms/templates/activation_complete.html index d845c5153b..27efbdc34f 100644 --- a/cms/templates/activation_complete.html +++ b/cms/templates/activation_complete.html @@ -2,12 +2,31 @@ <%inherit file="base.html" /> <%block name="content"> - -
-
-

${_("Activation Complete!")}

-

${_('Thanks for activating your account.')}${_("Log in here.")}

+
+
+

${_("Studio Account Activation")}

+
-
+
+
+
+
+ +
+
+

${_("Your account activation is complete!")}

+
+

${_("Thank you for activating your account. You may now sign in and start using edX Studio to author courses.")}

+
+
+ + +
+
+
diff --git a/cms/templates/activation_invalid.html b/cms/templates/activation_invalid.html index 3ee4e8ec4e..7f3fbed5a9 100644 --- a/cms/templates/activation_invalid.html +++ b/cms/templates/activation_invalid.html @@ -2,14 +2,32 @@ <%inherit file="base.html" /> <%block name="content"> -
-
-

${_("Activation Invalid")}

- -

${_('Something went wrong. Check to make sure the URL you went to was correct -- e-mail programs will sometimes split it into two lines. If you still have issues, e-mail us to let us know what happened at {email}.').format(email='bugs@mitx.mit.edu')}

- -

${_('Or you can go back to the {link_start}home page{link_end}.').format( - link_start='', link_end='')}

+
+
+

${_("Studio Account Activation")}

+
+
+ +
+
+
+
+ +
+
+

${_('Your account activation is invalid')}

+
+

${_("We're sorry. Something went wrong with your activation. Check to make sure the URL you went to was correct — e-mail programs will sometimes split it into two lines.")}

+

${_("If you still have issues, contact edX Support. In the meatime, you can also return to")} {_('the Studio homepage.')}

+
+
+ + +
+
-
diff --git a/cms/templates/emails/course_creator_denied.txt b/cms/templates/emails/course_creator_denied.txt new file mode 100644 index 0000000000..739ece6b6f --- /dev/null +++ b/cms/templates/emails/course_creator_denied.txt @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Your request for course creation rights to edX Studio have been denied. If you believe this was in error, please contact: ")} + +${ studio_request_email } diff --git a/cms/templates/emails/course_creator_granted.txt b/cms/templates/emails/course_creator_granted.txt new file mode 100644 index 0000000000..a867f94334 --- /dev/null +++ b/cms/templates/emails/course_creator_granted.txt @@ -0,0 +1,9 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Your request for course creation rights to edX Studio have been granted. To create your first course, visit:")} + +% if is_secure: +https://${ site } +% else: +http://${ site } +% endif diff --git a/cms/templates/emails/course_creator_revoked.txt b/cms/templates/emails/course_creator_revoked.txt new file mode 100644 index 0000000000..839c5a0d78 --- /dev/null +++ b/cms/templates/emails/course_creator_revoked.txt @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Your course creation rights to edX Studio have been revoked. If you believe this was in error, please contact: ")} + +${ studio_request_email } diff --git a/cms/templates/emails/course_creator_subject.txt b/cms/templates/emails/course_creator_subject.txt new file mode 100644 index 0000000000..35ec0ecc48 --- /dev/null +++ b/cms/templates/emails/course_creator_subject.txt @@ -0,0 +1,2 @@ +<%! from django.utils.translation import ugettext as _ %> +${_("Your course creator status for edX Studio")} diff --git a/cms/templates/index.html b/cms/templates/index.html index f0baef4f09..53c744c780 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -5,9 +5,41 @@ <%block name="title">${_("My Courses")} <%block name="bodyclass">is-signedin index dashboard + + + +<%block name="jsextra"> + + + <%block name="header_extras">