From bf493fffa3e4f319bb9d31ffdbb9b2faaab9b47c Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Fri, 19 Dec 2014 14:50:15 -0500
Subject: [PATCH 01/13] Add a django-rest-framework APIView that supports
reading/writing the current value of a configuration model
---
common/djangoapps/config_models/models.py | 2 +
common/djangoapps/config_models/tests.py | 91 ++++++++++++++++++++++-
common/djangoapps/config_models/views.py | 44 +++++++++++
3 files changed, 136 insertions(+), 1 deletion(-)
create mode 100644 common/djangoapps/config_models/views.py
diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py
index 415775e535..34b28f6c9c 100644
--- a/common/djangoapps/config_models/models.py
+++ b/common/djangoapps/config_models/models.py
@@ -93,6 +93,8 @@ class ConfigurationModel(models.Model):
"""
Clear the cached value when saving a new configuration entry
"""
+ # Always create a new entry, instead of updating an existing model
+ self.pk = None
super(ConfigurationModel, self).save(*args, **kwargs)
cache.delete(self.cache_key_name(*[getattr(self, key) for key in self.KEY_FIELDS]))
if self.KEY_FIELDS:
diff --git a/common/djangoapps/config_models/tests.py b/common/djangoapps/config_models/tests.py
index caa92ab657..8e76c9af01 100644
--- a/common/djangoapps/config_models/tests.py
+++ b/common/djangoapps/config_models/tests.py
@@ -7,10 +7,13 @@ import ddt
from django.contrib.auth.models import User
from django.db import models
from django.test import TestCase
+from rest_framework.test import APIRequestFactory
+
from freezegun import freeze_time
-from mock import patch
+from mock import patch, Mock
from config_models.models import ConfigurationModel
+from config_models.views import ConfigurationModelCurrentAPIView
class ExampleConfig(ConfigurationModel):
@@ -92,6 +95,14 @@ class ConfigurationModelTests(TestCase):
self.assertEqual(rows[1].string_field, 'first')
self.assertEqual(rows[1].is_active, False)
+ def test_always_insert(self, mock_cache):
+ config = ExampleConfig(changed_by=self.user, string_field='first')
+ config.save()
+ config.string_field = 'second'
+ config.save()
+
+ self.assertEquals(2, ExampleConfig.objects.all().count())
+
class ExampleKeyedConfig(ConfigurationModel):
"""
@@ -282,3 +293,81 @@ class KeyedConfigurationModelTests(TestCase):
fake_result = [('a', 'b'), ('c', 'd')]
mock_cache.get.return_value = fake_result
self.assertEquals(ExampleKeyedConfig.key_values(), fake_result)
+
+
+@ddt.ddt
+class ConfigurationModelAPITests(TestCase):
+ def setUp(self):
+ self.factory = APIRequestFactory()
+ self.user = User.objects.create_user(
+ username='test_user',
+ email='test_user@example.com',
+ password='test_pass',
+ )
+ self.user.is_superuser = True
+ self.user.save()
+
+ self.current_view = ConfigurationModelCurrentAPIView.as_view(model=ExampleConfig)
+
+ # Disable caching while testing the API
+ patcher = patch('config_models.models.cache', Mock(get=Mock(return_value=None)))
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_insert(self):
+ self.assertEquals("", ExampleConfig.current().string_field)
+
+ request = self.factory.post('/config/ExampleConfig', {"string_field": "string_value"})
+ request.user = self.user
+ response = self.current_view(request)
+
+ self.assertEquals("string_value", ExampleConfig.current().string_field)
+ self.assertEquals(self.user, ExampleConfig.current().changed_by)
+
+ def test_multiple_inserts(self):
+ for i in xrange(3):
+ self.assertEquals(i, ExampleConfig.objects.all().count())
+
+ request = self.factory.post('/config/ExampleConfig', {"string_field": str(i)})
+ request.user = self.user
+ response = self.current_view(request)
+ self.assertEquals(201, response.status_code)
+
+ self.assertEquals(i+1, ExampleConfig.objects.all().count())
+ self.assertEquals(str(i), ExampleConfig.current().string_field)
+
+ def test_get_current(self):
+ request = self.factory.get('/config/ExampleConfig')
+ request.user = self.user
+ response = self.current_view(request)
+ self.assertEquals('', response.data['string_field'])
+ self.assertEquals(10, response.data['int_field'])
+ self.assertEquals(None, response.data['changed_by'])
+ self.assertEquals(False, response.data['enabled'])
+ self.assertEquals(None, response.data['change_date'])
+
+ ExampleConfig(string_field='string_value', int_field=20).save()
+
+ response = self.current_view(request)
+ self.assertEquals('string_value', response.data['string_field'])
+ self.assertEquals(20, response.data['int_field'])
+
+ @ddt.data(
+ ('get', [], 200),
+ ('post', [{'string_field': 'string_value', 'int_field': 10}], 201),
+ )
+ @ddt.unpack
+ def test_permissions(self, method, args, status_code):
+ request = getattr(self.factory, method)('/config/ExampleConfig', *args)
+
+ request.user = User.objects.create_user(
+ username='no-perms',
+ email='no-perms@example.com',
+ password='no-perms',
+ )
+ response = self.current_view(request)
+ self.assertEquals(403, response.status_code)
+
+ request.user = self.user
+ response = self.current_view(request)
+ self.assertEquals(status_code, response.status_code)
diff --git a/common/djangoapps/config_models/views.py b/common/djangoapps/config_models/views.py
new file mode 100644
index 0000000000..e5bc1b899c
--- /dev/null
+++ b/common/djangoapps/config_models/views.py
@@ -0,0 +1,44 @@
+from rest_framework.generics import CreateAPIView, RetrieveAPIView
+from rest_framework.permissions import DjangoModelPermissions
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.serializers import ModelSerializer
+
+
+class ReadableOnlyByAuthors(DjangoModelPermissions):
+ perms_map = DjangoModelPermissions.perms_map.copy()
+ perms_map['GET'] = perms_map['OPTIONS'] = perms_map['HEAD'] = perms_map['POST']
+
+
+class ConfigurationModelCurrentAPIView(CreateAPIView, RetrieveAPIView):
+ """
+ This view allows an authenticated user with the appropriate model permissions
+ to read and write the current configuration for the specified `model`.
+
+ Like other APIViews, you can use this by using a url pattern similar to the following::
+
+ url(r'config/example_config$', ConfigurationModelCurrentAPIView.as_view(model=ExampleConfig))
+ """
+ authentication_classes = (SessionAuthentication,)
+ permission_classes = (ReadableOnlyByAuthors,)
+ model = None
+
+ def get_queryset(self):
+ return self.model.objects.all()
+
+ def get_object(self):
+ # Return the currently active configuration
+ return self.model.current()
+
+ def get_serializer_class(self):
+ if self.serializer_class is None:
+ class AutoConfigModelSerializer(ModelSerializer):
+ class Meta:
+ model = self.model
+
+ self.serializer_class = AutoConfigModelSerializer
+
+ return self.serializer_class
+
+ def perform_create(self, serializer):
+ # Set the requesting user as the one who is updating the configuration
+ serializer.save(changed_by = self.request.user)
From a6917e34f00fb83c89b3df60edfa10ccbbe96681 Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Fri, 19 Dec 2014 17:04:08 -0500
Subject: [PATCH 02/13] Teach auto_auth to create superusers
---
common/djangoapps/student/views.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 07669ee36a..76941f07ed 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -1796,6 +1796,7 @@ def auto_auth(request):
email = request.GET.get('email', unique_name + "@example.com")
full_name = request.GET.get('full_name', username)
is_staff = request.GET.get('staff', None)
+ is_superuser = request.GET.get('superuser', None)
course_id = request.GET.get('course_id', None)
# mode has to be one of 'honor'/'professional'/'verified'/'audit'/'no-id-professional'/'credit'
@@ -1836,6 +1837,10 @@ def auto_auth(request):
user.is_staff = (is_staff == "true")
user.save()
+ if is_superuser is not None:
+ user.is_superuser = (is_superuser == "true")
+ user.save()
+
# Activate the user
reg.activate()
reg.save()
From 49f9e31a0082015a73466d52cd6147d5a6c4afa0 Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Fri, 19 Dec 2014 17:04:30 -0500
Subject: [PATCH 03/13] Allow auto_auth on the LMS in bok_choy tests
---
lms/envs/bok_choy.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index 898504791e..4a4611f1dc 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -128,6 +128,9 @@ FEATURES['ENABLE_TEAMS'] = True
# Enable custom content licensing
FEATURES['LICENSING'] = True
+# Use the auto_auth workflow for creating users and logging them in
+FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
+
########################### Entrance Exams #################################
FEATURES['MILESTONES_APP'] = True
FEATURES['ENTRANCE_EXAMS'] = True
From 96b49759de3d51161a19eb6f773bb1ac3c89489c Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Thu, 22 Oct 2015 12:01:47 -0400
Subject: [PATCH 04/13] Add fixture for setting config models from Bok Choy.
---
common/test/acceptance/fixtures/config.py | 92 +++++++++++++++++++++++
1 file changed, 92 insertions(+)
create mode 100644 common/test/acceptance/fixtures/config.py
diff --git a/common/test/acceptance/fixtures/config.py b/common/test/acceptance/fixtures/config.py
new file mode 100644
index 0000000000..0e729dbee0
--- /dev/null
+++ b/common/test/acceptance/fixtures/config.py
@@ -0,0 +1,92 @@
+import requests
+import re
+import json
+
+from lazy import lazy
+from . import LMS_BASE_URL
+
+
+class ConfigModelFixureError(Exception):
+ """
+ Error occurred while configuring the stub XQueue.
+ """
+ pass
+
+
+class ConfigModelFixture(object):
+ """
+ Configure a ConfigurationModel by using it's JSON api.
+ """
+
+ def __init__(self, api_base, configuration):
+ """
+ Configure a ConfigurationModel exposed at `api_base` to have the configuration `configuration`.
+ """
+ self._api_base = api_base
+ self._configuration = configuration
+
+ def install(self):
+ """
+ Configure the stub via HTTP.
+ """
+ url = LMS_BASE_URL + self._api_base
+
+ response = self.session.post(
+ url,
+ data=json.dumps(self._configuration),
+ headers=self.headers,
+ )
+
+ if not response.ok:
+ raise ConfigModelFixureError(
+ "Could not configure url '{}'. response: {} - {}".format(
+ self._api_base,
+ response,
+ response.content,
+ )
+ )
+
+ @lazy
+ def session_cookies(self):
+ """
+ Log in as a staff user, then return the cookies for the session (as a dict)
+ Raises a `ConfigModelFixureError` if the login fails.
+ """
+ return {key: val for key, val in self.session.cookies.items()}
+
+ @lazy
+ def headers(self):
+ """
+ Default HTTP headers dict.
+ """
+ return {
+ 'Content-type': 'application/json',
+ 'Accept': 'application/json',
+ 'X-CSRFToken': self.session_cookies.get('csrftoken', '')
+ }
+
+ @lazy
+ def session(self):
+ """
+ Log in as a staff user, then return a `requests` `session` object for the logged in user.
+ Raises a `StudioApiLoginError` if the login fails.
+ """
+ # Use auto-auth to retrieve the session for a logged in user
+ session = requests.Session()
+ response = session.get(LMS_BASE_URL + "/auto_auth?superuser=true")
+
+ # Return the session from the request
+ if response.ok:
+ # auto_auth returns information about the newly created user
+ # capture this so it can be used by by the testcases.
+ user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
+ '(?P\S+)', '(?P[^\)]+)', '(?P\S+)', '(?P\d+)'))
+ user_matches = re.match(user_pattern, response.text)
+ if user_matches:
+ self.user = user_matches.groupdict()
+
+ return session
+
+ else:
+ msg = "Could not log in to use ConfigModel restful API. Status code: {0}".format(response.status_code)
+ raise ConfigModelFixureError(msg)
From eaf6be2a54b290259c09ec8ead2479888ca883ae Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Thu, 22 Oct 2015 13:17:20 -0400
Subject: [PATCH 05/13] Fix up quality errors in config model API.
---
common/djangoapps/config_models/models.py | 2 +-
common/djangoapps/config_models/tests.py | 11 ++++++++---
common/djangoapps/config_models/views.py | 10 ++++++++--
common/test/acceptance/fixtures/config.py | 9 ++++++---
4 files changed, 23 insertions(+), 9 deletions(-)
diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py
index 34b28f6c9c..af3ac85984 100644
--- a/common/djangoapps/config_models/models.py
+++ b/common/djangoapps/config_models/models.py
@@ -94,7 +94,7 @@ class ConfigurationModel(models.Model):
Clear the cached value when saving a new configuration entry
"""
# Always create a new entry, instead of updating an existing model
- self.pk = None
+ self.pk = None # pylint: disable=invalid-name
super(ConfigurationModel, self).save(*args, **kwargs)
cache.delete(self.cache_key_name(*[getattr(self, key) for key in self.KEY_FIELDS]))
if self.KEY_FIELDS:
diff --git a/common/djangoapps/config_models/tests.py b/common/djangoapps/config_models/tests.py
index 8e76c9af01..1bff0a3096 100644
--- a/common/djangoapps/config_models/tests.py
+++ b/common/djangoapps/config_models/tests.py
@@ -95,7 +95,7 @@ class ConfigurationModelTests(TestCase):
self.assertEqual(rows[1].string_field, 'first')
self.assertEqual(rows[1].is_active, False)
- def test_always_insert(self, mock_cache):
+ def test_always_insert(self, __):
config = ExampleConfig(changed_by=self.user, string_field='first')
config.save()
config.string_field = 'second'
@@ -297,7 +297,11 @@ class KeyedConfigurationModelTests(TestCase):
@ddt.ddt
class ConfigurationModelAPITests(TestCase):
+ """
+ Tests for the configuration model API.
+ """
def setUp(self):
+ super(ConfigurationModelAPITests, self).setUp()
self.factory = APIRequestFactory()
self.user = User.objects.create_user(
username='test_user',
@@ -319,7 +323,7 @@ class ConfigurationModelAPITests(TestCase):
request = self.factory.post('/config/ExampleConfig', {"string_field": "string_value"})
request.user = self.user
- response = self.current_view(request)
+ __ = self.current_view(request)
self.assertEquals("string_value", ExampleConfig.current().string_field)
self.assertEquals(self.user, ExampleConfig.current().changed_by)
@@ -333,13 +337,14 @@ class ConfigurationModelAPITests(TestCase):
response = self.current_view(request)
self.assertEquals(201, response.status_code)
- self.assertEquals(i+1, ExampleConfig.objects.all().count())
+ self.assertEquals(i + 1, ExampleConfig.objects.all().count())
self.assertEquals(str(i), ExampleConfig.current().string_field)
def test_get_current(self):
request = self.factory.get('/config/ExampleConfig')
request.user = self.user
response = self.current_view(request)
+ # pylint: disable=no-member
self.assertEquals('', response.data['string_field'])
self.assertEquals(10, response.data['int_field'])
self.assertEquals(None, response.data['changed_by'])
diff --git a/common/djangoapps/config_models/views.py b/common/djangoapps/config_models/views.py
index e5bc1b899c..2efcfa80bb 100644
--- a/common/djangoapps/config_models/views.py
+++ b/common/djangoapps/config_models/views.py
@@ -1,3 +1,6 @@
+"""
+API view to allow manipulation of configuration models.
+"""
from rest_framework.generics import CreateAPIView, RetrieveAPIView
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.authentication import SessionAuthentication
@@ -5,6 +8,7 @@ from rest_framework.serializers import ModelSerializer
class ReadableOnlyByAuthors(DjangoModelPermissions):
+ """Only allow access by users with `add` permissions on the model."""
perms_map = DjangoModelPermissions.perms_map.copy()
perms_map['GET'] = perms_map['OPTIONS'] = perms_map['HEAD'] = perms_map['POST']
@@ -32,7 +36,9 @@ class ConfigurationModelCurrentAPIView(CreateAPIView, RetrieveAPIView):
def get_serializer_class(self):
if self.serializer_class is None:
class AutoConfigModelSerializer(ModelSerializer):
- class Meta:
+ """Serializer class for configuration models."""
+ class Meta(object):
+ """Meta information for AutoConfigModelSerializer."""
model = self.model
self.serializer_class = AutoConfigModelSerializer
@@ -41,4 +47,4 @@ class ConfigurationModelCurrentAPIView(CreateAPIView, RetrieveAPIView):
def perform_create(self, serializer):
# Set the requesting user as the one who is updating the configuration
- serializer.save(changed_by = self.request.user)
+ serializer.save(changed_by=self.request.user)
diff --git a/common/test/acceptance/fixtures/config.py b/common/test/acceptance/fixtures/config.py
index 0e729dbee0..402ca030b1 100644
--- a/common/test/acceptance/fixtures/config.py
+++ b/common/test/acceptance/fixtures/config.py
@@ -1,3 +1,6 @@
+"""
+Fixture to manipulate configuration models.
+"""
import requests
import re
import json
@@ -79,11 +82,11 @@ class ConfigModelFixture(object):
if response.ok:
# auto_auth returns information about the newly created user
# capture this so it can be used by by the testcases.
- user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
- '(?P\S+)', '(?P[^\)]+)', '(?P\S+)', '(?P\d+)'))
+ user_pattern = re.compile(r'Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
+ r'(?P\S+)', r'(?P[^\)]+)', r'(?P\S+)', r'(?P\d+)'))
user_matches = re.match(user_pattern, response.text)
if user_matches:
- self.user = user_matches.groupdict()
+ self.user = user_matches.groupdict() # pylint: disable=attribute-defined-outside-init
return session
From dc7f09fc0e7a549d04bce3c294ba642409d85935 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Thu, 1 Oct 2015 11:36:07 -0400
Subject: [PATCH 06/13] Add self_paced field to course module.
---
cms/djangoapps/models/settings/course_metadata.py | 1 +
common/lib/xmodule/xmodule/course_module.py | 11 +++++++++++
.../lib/xmodule/xmodule/tests/test_course_module.py | 11 +++++++++++
3 files changed, 23 insertions(+)
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
index f8ca9c534b..ef8129ecea 100644
--- a/cms/djangoapps/models/settings/course_metadata.py
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -50,6 +50,7 @@ class CourseMetadata(object):
'is_proctored_enabled',
'is_time_limited',
'is_practice_exam',
+ 'self_paced'
]
@classmethod
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index ecec841ee9..1b52885361 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -928,6 +928,17 @@ class CourseFields(object):
scope=Scope.settings,
)
+ self_paced = Boolean(
+ display_name=_("Self Paced"),
+ help=_(
+ "Set this to \"true\" to mark this course as self-paced. Self-paced courses do not have "
+ "due dates for assignments, and students can progress through the course at any rate before "
+ "the course ends."
+ ),
+ default=False,
+ scope=Scope.settings
+ )
+
class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method
"""
diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py
index db594abe09..23c0f330ed 100644
--- a/common/lib/xmodule/xmodule/tests/test_course_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_course_module.py
@@ -354,6 +354,17 @@ class TeamsConfigurationTestCase(unittest.TestCase):
self.assertEqual(self.course.teams_topics, topics)
+class SelfPacedTestCase(unittest.TestCase):
+ """Tests for self-paced courses."""
+
+ def setUp(self):
+ super(SelfPacedTestCase, self).setUp()
+ self.course = get_dummy_course('2012-12-02T12:00')
+
+ def test_default(self):
+ self.assertFalse(self.course.self_paced)
+
+
class CourseDescriptorTestCase(unittest.TestCase):
"""
Tests for a select few functions from CourseDescriptor.
From 15d77fda3fcaa4f92b6c3caf9353f040b612e649 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Mon, 5 Oct 2015 11:51:02 -0400
Subject: [PATCH 07/13] Hide due/release dates on course outline in Studio.
ECOM-2443
---
.../js/views/modals/course_outline_modals.js | 4 ++
cms/static/js/views/xblock_outline.js | 4 +-
cms/templates/base.html | 3 +-
cms/templates/js/course-outline.underscore | 26 +++++-----
.../tests/studio/test_studio_outline.py | 50 +++++++++++++++++++
5 files changed, 73 insertions(+), 14 deletions(-)
diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js
index 5b00400447..416c9a5b8e 100644
--- a/cms/static/js/views/modals/course_outline_modals.js
+++ b/cms/static/js/views/modals/course_outline_modals.js
@@ -584,6 +584,10 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
editors.push(VerificationAccessEditor);
}
}
+ /* globals course */
+ if (course.get('self_paced')) {
+ editors = _.without(editors, ReleaseDateEditor, DueDateEditor);
+ }
return new SettingsXBlockModal($.extend({
editors: editors,
model: xblockInfo
diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js
index ad110ab1a5..2d4e04398d 100644
--- a/cms/static/js/views/xblock_outline.js
+++ b/cms/static/js/views/xblock_outline.js
@@ -89,6 +89,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
}, true);
defaultNewChildName = childInfo.display_name;
}
+ /* globals course */
return {
xblockInfo: xblockInfo,
visibilityClass: XBlockViewUtils.getXBlockVisibilityClass(xblockInfo.get('visibility_state')),
@@ -104,7 +105,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
isCollapsed: isCollapsed,
includesChildren: this.shouldRenderChildren(),
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
- staffOnlyMessage: this.model.get('staff_only_message')
+ staffOnlyMessage: this.model.get('staff_only_message'),
+ course: course
};
},
diff --git a/cms/templates/base.html b/cms/templates/base.html
index 2c4564abdf..8dd516495c 100644
--- a/cms/templates/base.html
+++ b/cms/templates/base.html
@@ -85,7 +85,8 @@ import json
org: "${context_course.location.org | h}",
num: "${context_course.location.course | h}",
display_course_number: "${_(context_course.display_coursenumber)}",
- revision: "${context_course.location.revision | h}"
+ revision: "${context_course.location.revision | h}",
+ self_paced: ${json.dumps(context_course.self_paced)}
});
});
% endif
diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore
index 94da74b5f0..749ce52798 100644
--- a/cms/templates/js/course-outline.underscore
+++ b/cms/templates/js/course-outline.underscore
@@ -113,18 +113,20 @@ if (xblockInfo.get('graded')) {
<%= gettext('Release Status:') %>
- <% if (xblockInfo.get('released_to_students')) { %>
-
- <%= gettext('Released:') %>
- <% } else if (xblockInfo.get('release_date')) { %>
-
- <%= gettext('Scheduled:') %>
- <% } else { %>
-
- <%= gettext('Unscheduled') %>
- <% } %>
- <% if (xblockInfo.get('release_date')) { %>
- <%= xblockInfo.get('release_date') %>
+ <% if (!course.get('self_paced')) { %>
+ <% if (xblockInfo.get('released_to_students')) { %>
+
+ <%= gettext('Released:') %>
+ <% } else if (xblockInfo.get('release_date')) { %>
+
+ <%= gettext('Scheduled:') %>
+ <% } else { %>
+
+ <%= gettext('Unscheduled') %>
+ <% } %>
+ <% if (xblockInfo.get('release_date')) { %>
+ <%= xblockInfo.get('release_date') %>
+ <% } %>
<% } %>
diff --git a/common/test/acceptance/tests/studio/test_studio_outline.py b/common/test/acceptance/tests/studio/test_studio_outline.py
index 05f8693538..76c8e7b1af 100644
--- a/common/test/acceptance/tests/studio/test_studio_outline.py
+++ b/common/test/acceptance/tests/studio/test_studio_outline.py
@@ -1752,3 +1752,53 @@ class DeprecationWarningMessageTest(CourseOutlineTest):
components_present=True,
components_display_name_list=['Open', 'Peer']
)
+
+
+class SelfPacedOutlineTest(CourseOutlineTest):
+ """Test the course outline for a self-paced course."""
+
+ def populate_course_fixture(self, course_fixture):
+ course_fixture.add_children(
+ XBlockFixtureDesc('chapter', SECTION_NAME).add_children(
+ XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children(
+ XBlockFixtureDesc('vertical', UNIT_NAME)
+ )
+ ),
+ )
+ self.course_fixture.add_course_details({'self_paced': True})
+
+ def test_release_dates_not_shown(self):
+ """
+ Scenario: Ensure that block release dates are not shown on the
+ course outline page of a self-paced course.
+
+ Given I am the author of a self-paced course
+ When I go to the course outline
+ Then I should not see release dates for course content
+ """
+ self.course_outline_page.visit()
+ section = self.course_outline_page.section(SECTION_NAME)
+ self.assertEqual(section.release_date, '')
+ subsection = section.subsection(SUBSECTION_NAME)
+ self.assertEqual(subsection.release_date, '')
+
+ def test_edit_section_and_subsection(self):
+ """
+ Scenario: Ensure that block release/due dates are not shown
+ in their settings modals.
+
+ Given I am the author of a self-paced course
+ When I go to the course outline
+ And I click on settings for a section or subsection
+ Then I should not see release or due date settings
+ """
+ self.course_outline_page.visit()
+ section = self.course_outline_page.section(SECTION_NAME)
+ modal = section.edit()
+ self.assertFalse(modal.has_release_date())
+ self.assertFalse(modal.has_due_date())
+ modal.cancel()
+ subsection = section.subsection(SUBSECTION_NAME)
+ modal = subsection.edit()
+ self.assertFalse(modal.has_release_date())
+ self.assertFalse(modal.has_due_date())
From 9d88bef1175c25c3571632a0a5bedc61caf30844 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Tue, 6 Oct 2015 10:57:35 -0400
Subject: [PATCH 08/13] Allow setting `self_paced` through course details
endpoint.
ECOM-2489
---
cms/djangoapps/contentstore/tests/test_course_settings.py | 7 +++++++
cms/djangoapps/models/settings/course_details.py | 6 ++++++
2 files changed, 13 insertions(+)
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 94ec075468..e26395fd64 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -56,6 +56,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
self.assertIsNone(details.language, "language somehow initialized" + str(details.language))
self.assertIsNone(details.has_cert_config)
+ self.assertFalse(details.self_paced)
def test_encoder(self):
details = CourseDetails.fetch(self.course.id)
@@ -128,6 +129,11 @@ class CourseDetailsTestCase(CourseTestCase):
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).language,
jsondetails.language
)
+ jsondetails.self_paced = True
+ self.assertEqual(
+ CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).self_paced,
+ jsondetails.self_paced
+ )
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
@@ -334,6 +340,7 @@ class CourseDetailsViewTest(CourseTestCase):
self.alter_field(url, details, 'effort', "effort")
self.alter_field(url, details, 'course_image_name', "course_image_name")
self.alter_field(url, details, 'language', "en")
+ self.alter_field(url, details, 'self_paced', "true")
def compare_details_with_encoding(self, encoded, details, context):
"""
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index 4483b1145f..e3391d0b96 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -54,6 +54,7 @@ class CourseDetails(object):
'50'
) # minimum passing score for entrance exam content module/tree,
self.has_cert_config = None # course has active certificate configuration
+ self.self_paced = None
@classmethod
def _fetch_about_attribute(cls, course_key, attribute):
@@ -86,6 +87,7 @@ class CourseDetails(object):
# Default course license is "All Rights Reserved"
course_details.license = getattr(descriptor, "license", "all-rights-reserved")
course_details.has_cert_config = has_active_web_certificate(descriptor)
+ course_details.self_paced = descriptor.self_paced
for attribute in ABOUT_ATTRIBUTES:
value = cls._fetch_about_attribute(course_key, attribute)
@@ -188,6 +190,10 @@ class CourseDetails(object):
descriptor.language = jsondict['language']
dirty = True
+ if 'self_paced' in jsondict and jsondict['self_paced'] != descriptor.self_paced:
+ descriptor.self_paced = jsondict['self_paced']
+ dirty = True
+
if dirty:
module_store.update_item(descriptor, user.id)
From 0107525d419a892782da39a9bce16dcbe84b8a68 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Wed, 7 Oct 2015 15:28:47 -0400
Subject: [PATCH 09/13] Enable self-paced courses behind a feature flag.
---
cms/djangoapps/models/settings/course_details.py | 10 +++++++---
cms/envs/bok_choy.py | 3 +++
cms/envs/common.py | 3 +++
cms/envs/test.py | 3 +++
lms/envs/bok_choy.py | 3 +++
lms/envs/common.py | 3 +++
lms/envs/test.py | 3 +++
7 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index e3391d0b96..c83fc475f0 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -54,7 +54,8 @@ class CourseDetails(object):
'50'
) # minimum passing score for entrance exam content module/tree,
self.has_cert_config = None # course has active certificate configuration
- self.self_paced = None
+ if settings.FEATURES.get('ENABLE_SELF_PACED_COURSES'):
+ self.self_paced = None
@classmethod
def _fetch_about_attribute(cls, course_key, attribute):
@@ -87,7 +88,8 @@ class CourseDetails(object):
# Default course license is "All Rights Reserved"
course_details.license = getattr(descriptor, "license", "all-rights-reserved")
course_details.has_cert_config = has_active_web_certificate(descriptor)
- course_details.self_paced = descriptor.self_paced
+ if settings.FEATURES.get('ENABLE_SELF_PACED_COURSES'):
+ course_details.self_paced = descriptor.self_paced
for attribute in ABOUT_ATTRIBUTES:
value = cls._fetch_about_attribute(course_key, attribute)
@@ -190,7 +192,9 @@ class CourseDetails(object):
descriptor.language = jsondict['language']
dirty = True
- if 'self_paced' in jsondict and jsondict['self_paced'] != descriptor.self_paced:
+ if (settings.FEATURES.get('ENABLE_SELF_PACED_COURSES')
+ and 'self_paced' in jsondict
+ and jsondict['self_paced'] != descriptor.self_paced):
descriptor.self_paced = jsondict['self_paced']
dirty = True
diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py
index 817eedac89..8fa79caea5 100644
--- a/cms/envs/bok_choy.py
+++ b/cms/envs/bok_choy.py
@@ -106,6 +106,9 @@ FEATURES['ENTRANCE_EXAMS'] = True
FEATURES['ENABLE_PROCTORED_EXAMS'] = True
+# Enable self-paced courses
+FEATURES['ENABLE_SELF_PACED_COURSES'] = True
+
# Point the URL used to test YouTube availability to our stub YouTube server
YOUTUBE_PORT = 9080
YOUTUBE['API'] = "http://127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 67c04bab7b..f5546b3238 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -182,6 +182,9 @@ FEATURES = {
# Timed or Proctored Exams
'ENABLE_PROCTORED_EXAMS': False,
+
+ # Enable self-paced courses.
+ 'ENABLE_SELF_PACED_COURSES': False,
}
ENABLE_JASMINE = False
diff --git a/cms/envs/test.py b/cms/envs/test.py
index b96e0c02b3..159fbd63c5 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -279,3 +279,6 @@ FEATURES['ENABLE_TEAMS'] = True
# Dummy secret key for dev/test
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
+
+# Enable self-paced courses
+FEATURES['ENABLE_SELF_PACED_COURSES'] = True
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index 4a4611f1dc..eee77ded80 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -131,6 +131,9 @@ FEATURES['LICENSING'] = True
# Use the auto_auth workflow for creating users and logging them in
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
+# Enable self-paced courses
+FEATURES['ENABLE_SELF_PACED_COURSES'] = True
+
########################### Entrance Exams #################################
FEATURES['MILESTONES_APP'] = True
FEATURES['ENTRANCE_EXAMS'] = True
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 3d3900cb9b..b338ffcb85 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -408,6 +408,9 @@ FEATURES = {
# Enable LTI Provider feature.
'ENABLE_LTI_PROVIDER': False,
+
+ # Enable self-paced courses.
+ 'ENABLE_SELF_PACED_COURSES': False,
}
# Ignore static asset files on import which match this pattern
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 0ed5986cf7..6d2f46bba9 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -532,3 +532,6 @@ AUTHENTICATION_BACKENDS += ('lti_provider.users.LtiBackend',)
# ORGANIZATIONS
FEATURES['ORGANIZATIONS_APP'] = True
+
+# Enable self-paced courses
+FEATURES['ENABLE_SELF_PACED_COURSES'] = True
From 7f673604fbd3a7559cfb71358da3a0d0ff6ae9d6 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Thu, 8 Oct 2015 13:39:59 -0400
Subject: [PATCH 10/13] Allow setting self-paced on schedule & details page.
Currently unstyled.
ECOM-2462
---
.../tests/test_course_settings.py | 5 +++
cms/static/js/views/settings/main.js | 12 ++++++
cms/templates/settings.html | 18 ++++++++
.../test/acceptance/pages/studio/settings.py | 21 +++++++++
.../studio/test_studio_settings_details.py | 43 ++++++++++++++++---
5 files changed, 94 insertions(+), 5 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index e26395fd64..2e0e516632 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -135,6 +135,11 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails.self_paced
)
+ @override_settings(FEATURES=dict(settings.FEATURES, ENABLE_SELF_PACED_COURSES=False))
+ def test_enable_self_paced(self):
+ details = CourseDetails.fetch(self.course.id)
+ self.assertNotIn('self_paced', details.__dict__)
+
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
settings_details_url = get_url(self.course.id)
diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js
index 9e2486491c..1188c2b6eb 100644
--- a/cms/static/js/views/settings/main.js
+++ b/cms/static/js/views/settings/main.js
@@ -99,6 +99,13 @@ var DetailsView = ValidatingView.extend({
}
this.$('#' + this.fieldToSelectorMap['entrance_exam_minimum_score_pct']).val(this.model.get('entrance_exam_minimum_score_pct'));
+ if (this.model.get('self_paced')) {
+ this.$('#course-pace-self-paced').attr('checked', true);
+ }
+ else {
+ this.$('#course-pace-instructor-led').attr('checked', true);
+ }
+
this.licenseView.render()
return this;
@@ -236,6 +243,11 @@ var DetailsView = ValidatingView.extend({
}
}, this), 1000);
break;
+ case 'course-pace-self-paced':
+ // Fallthrough to handle both radio buttons
+ case 'course-pace-instructor-led':
+ this.model.set('self_paced', JSON.parse(event.currentTarget.value));
+ break;
default: // Everything else is handled by datepickers and CodeMirror.
break;
}
diff --git a/cms/templates/settings.html b/cms/templates/settings.html
index c54d944228..570c4371a5 100644
--- a/cms/templates/settings.html
+++ b/cms/templates/settings.html
@@ -412,6 +412,24 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
% endif
+ % if settings.FEATURES.get("ENABLE_SELF_PACED_COURSES", False):
+
+
+
+
+
+ % endif
+
% if settings.FEATURES.get("LICENSING", False):
diff --git a/common/test/acceptance/pages/studio/settings.py b/common/test/acceptance/pages/studio/settings.py
index 9e0c3d90d7..f7fd434d97 100644
--- a/common/test/acceptance/pages/studio/settings.py
+++ b/common/test/acceptance/pages/studio/settings.py
@@ -131,6 +131,27 @@ class SettingsPage(CoursePage):
raise Exception("Invalid license name: {name}".format(name=license_name))
button.click()
+ pacing_css = 'section.pacing input[type=radio]:checked'
+
+ @property
+ def course_pacing(self):
+ """
+ Returns the label text corresponding to the checked pacing radio button.
+ """
+ self.wait_for_element_presence(self.pacing_css, 'course pacing controls present and rendered')
+ checked = self.q(css=self.pacing_css).results[0]
+ checked_id = checked.get_attribute('id')
+ return self.q(css='label[for={checked_id}]'.format(checked_id=checked_id)).results[0].text
+
+ @course_pacing.setter
+ def course_pacing(self, pacing):
+ """
+ Sets the course to either self-paced or instructor-led by checking
+ the appropriate radio button.
+ """
+ self.wait_for_element_presence(self.pacing_css, 'course pacing controls present')
+ self.q(xpath="//label[contains(text(), '{pacing}')]".format(pacing=pacing)).click()
+
################
# Waits
################
diff --git a/common/test/acceptance/tests/studio/test_studio_settings_details.py b/common/test/acceptance/tests/studio/test_studio_settings_details.py
index 0e68e69708..6cff8d6225 100644
--- a/common/test/acceptance/tests/studio/test_studio_settings_details.py
+++ b/common/test/acceptance/tests/studio/test_studio_settings_details.py
@@ -16,12 +16,11 @@ from ..helpers import (
)
-class SettingsMilestonesTest(StudioCourseTest):
- """
- Tests for milestones feature in Studio's settings tab
- """
+class StudioSettingsDetailsTest(StudioCourseTest):
+ """Base class for settings and details page tests."""
+
def setUp(self, is_staff=True):
- super(SettingsMilestonesTest, self).setUp(is_staff=is_staff)
+ super(StudioSettingsDetailsTest, self).setUp(is_staff=is_staff)
self.settings_detail = SettingsPage(
self.browser,
self.course_info['org'],
@@ -33,6 +32,11 @@ class SettingsMilestonesTest(StudioCourseTest):
self.settings_detail.visit()
self.assertTrue(self.settings_detail.is_browser_on_page())
+
+class SettingsMilestonesTest(StudioSettingsDetailsTest):
+ """
+ Tests for milestones feature in Studio's settings tab
+ """
def test_page_has_prerequisite_field(self):
"""
Test to make sure page has pre-requisite course field if milestones app is enabled.
@@ -193,3 +197,32 @@ class SettingsMilestonesTest(StudioCourseTest):
css_selector='.add-item a.button-new',
text='New Subsection'
))
+
+
+class CoursePacingTest(StudioSettingsDetailsTest):
+ """Tests for setting a course to self-paced."""
+
+ def test_default_instructor_led(self):
+ """
+ Test that the 'instructor led' button is checked by default.
+ """
+ self.assertEqual(self.settings_detail.course_pacing, 'Instructor Led')
+
+ def test_self_paced(self):
+ """
+ Test that the 'self-paced' button is checked for a self-paced
+ course.
+ """
+ self.course_fixture.add_course_details({'self_paced': True})
+ self.course_fixture.configure_course()
+ self.settings_detail.refresh_page()
+ self.assertEqual(self.settings_detail.course_pacing, 'Self-Paced')
+
+ def test_set_self_paced(self):
+ """
+ Test that the self-paced option is persisted correctly.
+ """
+ self.settings_detail.course_pacing = 'Self-Paced'
+ self.settings_detail.save_changes()
+ self.settings_detail.refresh_page()
+ self.assertEqual(self.settings_detail.course_pacing, 'Self-Paced')
From 4805946a83170a3eb3fab8c8df91fb43b6f60f4a Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Fri, 9 Oct 2015 14:29:51 -0400
Subject: [PATCH 11/13] Override due dates in the LMS for self-paced courses.
---
lms/djangoapps/ccx/tests/test_overrides.py | 9 +---
lms/djangoapps/courseware/field_overrides.py | 13 ++---
.../courseware/self_paced_overrides.py | 23 +++++++++
.../courseware/tests/test_field_overrides.py | 13 +++++
.../tests/test_self_paced_overrides.py | 49 +++++++++++++++++++
lms/djangoapps/instructor/tests/test_tools.py | 9 +---
lms/envs/aws.py | 6 +++
7 files changed, 102 insertions(+), 20 deletions(-)
create mode 100644 lms/djangoapps/courseware/self_paced_overrides.py
create mode 100644 lms/djangoapps/courseware/tests/test_self_paced_overrides.py
diff --git a/lms/djangoapps/ccx/tests/test_overrides.py b/lms/djangoapps/ccx/tests/test_overrides.py
index dafd442872..d88a8d4c35 100644
--- a/lms/djangoapps/ccx/tests/test_overrides.py
+++ b/lms/djangoapps/ccx/tests/test_overrides.py
@@ -9,6 +9,7 @@ from nose.plugins.attrib import attr
from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
from django.test.utils import override_settings
+from lms.djangoapps.courseware.tests.test_field_overrides import inject_field_overrides
from request_cache.middleware import RequestCache
from student.tests.factories import AdminFactory # pylint: disable=import-error
from xmodule.modulestore.tests.django_utils import (
@@ -69,13 +70,7 @@ class TestFieldOverrides(ModuleStoreTestCase):
self.addCleanup(RequestCache.clear_request_cache)
- # Apparently the test harness doesn't use LmsFieldStorage, and I'm not
- # sure if there's a way to poke the test harness to do so. So, we'll
- # just inject the override field storage in this brute force manner.
- OverrideFieldData.provider_classes = None
- for block in iter_blocks(ccx.course):
- block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access
- AdminFactory.create(), course, block._field_data) # pylint: disable=protected-access
+ inject_field_overrides(iter_blocks(ccx.course), course, AdminFactory.create())
def cleanup_provider_classes():
"""
diff --git a/lms/djangoapps/courseware/field_overrides.py b/lms/djangoapps/courseware/field_overrides.py
index e5e50fd7de..44207e1e71 100644
--- a/lms/djangoapps/courseware/field_overrides.py
+++ b/lms/djangoapps/courseware/field_overrides.py
@@ -24,7 +24,7 @@ from xblock.field_data import FieldData
from xmodule.modulestore.inheritance import InheritanceMixin
NOTSET = object()
-ENABLED_OVERRIDE_PROVIDERS_KEY = "courseware.field_overrides.enabled_providers"
+ENABLED_OVERRIDE_PROVIDERS_KEY = "courseware.field_overrides.enabled_providers.{course_id}"
def resolve_dotted(name):
@@ -77,7 +77,6 @@ class OverrideFieldData(FieldData):
settings.FIELD_OVERRIDE_PROVIDERS))
enabled_providers = cls._providers_for_course(course)
-
if enabled_providers:
# TODO: we might not actually want to return here. Might be better
# to check for instance.providers after the instance is built. This
@@ -98,14 +97,16 @@ class OverrideFieldData(FieldData):
course: The course XBlock
"""
request_cache = RequestCache.get_request_cache()
- enabled_providers = request_cache.data.get(
- ENABLED_OVERRIDE_PROVIDERS_KEY, NOTSET
- )
+ if course is None:
+ cache_key = ENABLED_OVERRIDE_PROVIDERS_KEY.format(course_id='None')
+ else:
+ cache_key = ENABLED_OVERRIDE_PROVIDERS_KEY.format(course_id=unicode(course.id))
+ enabled_providers = request_cache.data.get(cache_key, NOTSET)
if enabled_providers == NOTSET:
enabled_providers = tuple(
(provider_class for provider_class in cls.provider_classes if provider_class.enabled_for(course))
)
- request_cache.data[ENABLED_OVERRIDE_PROVIDERS_KEY] = enabled_providers
+ request_cache.data[cache_key] = enabled_providers
return enabled_providers
diff --git a/lms/djangoapps/courseware/self_paced_overrides.py b/lms/djangoapps/courseware/self_paced_overrides.py
new file mode 100644
index 0000000000..abd7b389c2
--- /dev/null
+++ b/lms/djangoapps/courseware/self_paced_overrides.py
@@ -0,0 +1,23 @@
+"""
+Field overrides for self-paced courses. This allows overriding due
+dates for each block in the course.
+"""
+
+from .field_overrides import FieldOverrideProvider
+
+
+class SelfPacedDateOverrideProvider(FieldOverrideProvider):
+ """
+ A concrete implementation of
+ :class:`~courseware.field_overrides.FieldOverrideProvider` which allows for
+ due dates to be overridden for self-paced courses.
+ """
+ def get(self, block, name, default):
+ if name == 'due':
+ return None
+ return default
+
+ @classmethod
+ def enabled_for(cls, course):
+ """This provider is enabled for self-paced courses only."""
+ return course.self_paced
diff --git a/lms/djangoapps/courseware/tests/test_field_overrides.py b/lms/djangoapps/courseware/tests/test_field_overrides.py
index 89e98fe192..24f6d61859 100644
--- a/lms/djangoapps/courseware/tests/test_field_overrides.py
+++ b/lms/djangoapps/courseware/tests/test_field_overrides.py
@@ -132,3 +132,16 @@ class TestOverrideProvider(FieldOverrideProvider):
@classmethod
def enabled_for(cls, course):
return True
+
+
+def inject_field_overrides(blocks, course, user):
+ """
+ Apparently the test harness doesn't use LmsFieldStorage, and I'm
+ not sure if there's a way to poke the test harness to do so. So,
+ we'll just inject the override field storage in this brute force
+ manner.
+ """
+ OverrideFieldData.provider_classes = None
+ for block in blocks:
+ block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access
+ user, course, block._field_data) # pylint: disable=protected-access
diff --git a/lms/djangoapps/courseware/tests/test_self_paced_overrides.py b/lms/djangoapps/courseware/tests/test_self_paced_overrides.py
new file mode 100644
index 0000000000..e0df82b6e6
--- /dev/null
+++ b/lms/djangoapps/courseware/tests/test_self_paced_overrides.py
@@ -0,0 +1,49 @@
+"""
+Tests for self-paced course due date overrides.
+"""
+
+from datetime import datetime
+from dateutil.tz import tzutc
+from django.test.utils import override_settings
+
+from student.tests.factories import UserFactory
+from lms.djangoapps.ccx.tests.test_overrides import inject_field_overrides
+from lms.djangoapps.courseware.field_overrides import OverrideFieldData
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+
+
+@override_settings(
+ FIELD_OVERRIDE_PROVIDERS=('courseware.self_paced_overrides.SelfPacedDateOverrideProvider',)
+)
+class SelfPacedDateOverrideTest(ModuleStoreTestCase):
+ """
+ Tests for self-paced due date overrides.
+ """
+
+ def setUp(self):
+ super(SelfPacedDateOverrideTest, self).setUp()
+ self.due_date = datetime(2015, 5, 26, 8, 30, 00).replace(tzinfo=tzutc())
+ self.instructor_led_course, self.il_section = self.setup_course("Instructor Led Course", False)
+ self.self_paced_course, self.sp_section = self.setup_course("Self-Paced Course", True)
+
+ def tearDown(self):
+ super(SelfPacedDateOverrideTest, self).tearDown()
+ OverrideFieldData.provider_classes = None
+
+ def setup_course(self, display_name, self_paced):
+ """Set up a course with `display_name` and `self_paced` attributes.
+
+ Creates a child block with a due date, and ensures that field
+ overrides are correctly applied for both blocks.
+ """
+ course = CourseFactory.create(display_name=display_name, self_paced=self_paced)
+ section = ItemFactory.create(parent=course, due=self.due_date)
+ inject_field_overrides((course, section), course, UserFactory.create())
+ return (course, section)
+
+ def test_instructor_led(self):
+ self.assertEqual(self.due_date, self.il_section.due)
+
+ def test_self_paced(self):
+ self.assertIsNone(self.sp_section.due)
diff --git a/lms/djangoapps/instructor/tests/test_tools.py b/lms/djangoapps/instructor/tests/test_tools.py
index 54068d4ce2..9ea90f6773 100644
--- a/lms/djangoapps/instructor/tests/test_tools.py
+++ b/lms/djangoapps/instructor/tests/test_tools.py
@@ -12,6 +12,7 @@ from django.test.utils import override_settings
from nose.plugins.attrib import attr
from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
+from lms.djangoapps.ccx.tests.test_overrides import inject_field_overrides
from student.tests.factories import UserFactory # pylint: disable=import-error
from xmodule.fields import Date
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
@@ -196,7 +197,6 @@ class TestSetDueDateExtension(ModuleStoreTestCase):
Fixtures.
"""
super(TestSetDueDateExtension, self).setUp()
- OverrideFieldData.provider_classes = None
self.due = due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc)
course = CourseFactory.create()
@@ -216,12 +216,7 @@ class TestSetDueDateExtension(ModuleStoreTestCase):
self.week3 = week3
self.user = user
- # Apparently the test harness doesn't use LmsFieldStorage, and I'm not
- # sure if there's a way to poke the test harness to do so. So, we'll
- # just inject the override field storage in this brute force manner.
- for block in (course, week1, week2, week3, homework, assignment):
- block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access
- user, course, block._field_data) # pylint: disable=protected-access
+ inject_field_overrides((course, week1, week2, week3, homework, assignment), course, user)
def tearDown(self):
super(TestSetDueDateExtension, self).tearDown()
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 30cac67656..3d3008c223 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -676,6 +676,12 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'):
'courseware.student_field_overrides.IndividualStudentOverrideProvider',
)
+##### Self-Paced Course Due Dates #####
+if FEATURES.get('ENABLE_SELF_PACED_COURSES'):
+ FIELD_OVERRIDE_PROVIDERS += (
+ 'courseware.self_paced_overrides.SelfPacedDateOverrideProvider',
+ )
+
# PROFILE IMAGE CONFIG
PROFILE_IMAGE_BACKEND = ENV_TOKENS.get('PROFILE_IMAGE_BACKEND', PROFILE_IMAGE_BACKEND)
PROFILE_IMAGE_SECRET_KEY = AUTH_TOKENS.get('PROFILE_IMAGE_SECRET_KEY', PROFILE_IMAGE_SECRET_KEY)
From 5ffa06bed118867091d7636c6d43334aa1432457 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Wed, 21 Oct 2015 17:08:26 -0400
Subject: [PATCH 12/13] Responding to review comments.
---
.../tests/test_course_settings.py | 8 +--
cms/djangoapps/contentstore/views/course.py | 7 +-
.../models/settings/course_details.py | 9 ++-
cms/envs/bok_choy.py | 3 -
cms/envs/common.py | 6 +-
cms/envs/test.py | 3 -
cms/templates/settings.html | 2 +-
.../tests/studio/test_studio_outline.py | 2 +
.../studio/test_studio_settings_details.py | 4 ++
.../courseware/self_paced_overrides.py | 3 +-
.../courseware/tests/test_course_info.py | 36 +++++++++-
.../tests/test_self_paced_overrides.py | 15 ++--
lms/envs/aws.py | 7 +-
lms/envs/bok_choy.py | 3 -
lms/envs/common.py | 6 +-
lms/envs/test.py | 3 -
lms/urls.py | 7 ++
.../core/djangoapps/self_paced/__init__.py | 0
openedx/core/djangoapps/self_paced/admin.py | 10 +++
.../self_paced/migrations/0001_initial.py | 72 +++++++++++++++++++
.../self_paced/migrations/__init__.py | 0
openedx/core/djangoapps/self_paced/models.py | 12 ++++
22 files changed, 177 insertions(+), 41 deletions(-)
create mode 100644 openedx/core/djangoapps/self_paced/__init__.py
create mode 100644 openedx/core/djangoapps/self_paced/admin.py
create mode 100644 openedx/core/djangoapps/self_paced/migrations/0001_initial.py
create mode 100644 openedx/core/djangoapps/self_paced/migrations/__init__.py
create mode 100644 openedx/core/djangoapps/self_paced/models.py
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 2e0e516632..5b65ccbf44 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -30,6 +30,7 @@ from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY
import ddt
from xmodule.modulestore import ModuleStoreEnum
+from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from util.milestones_helpers import seed_milestone_relationship_types
@@ -87,6 +88,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertEqual(jsondetails['string'], 'string')
def test_update_and_fetch(self):
+ SelfPacedConfiguration(enabled=True).save()
jsondetails = CourseDetails.fetch(self.course.id)
jsondetails.syllabus = "bar "
# encode - decode to convert date fields and other data which changes form
@@ -135,11 +137,6 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails.self_paced
)
- @override_settings(FEATURES=dict(settings.FEATURES, ENABLE_SELF_PACED_COURSES=False))
- def test_enable_self_paced(self):
- details = CourseDetails.fetch(self.course.id)
- self.assertNotIn('self_paced', details.__dict__)
-
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
settings_details_url = get_url(self.course.id)
@@ -325,6 +322,7 @@ class CourseDetailsViewTest(CourseTestCase):
return Date().to_json(datetime_obj)
def test_update_and_fetch(self):
+ SelfPacedConfiguration(enabled=True).save()
details = CourseDetails.fetch(self.course.id)
# resp s/b json from here on
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 1f81fb29e2..4ff5151e16 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -28,6 +28,7 @@ from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
+from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from xmodule.modulestore import EdxJSONEncoder
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
from opaque_keys import InvalidKeyError
@@ -913,6 +914,9 @@ def settings_handler(request, course_key_string):
about_page_editable = not marketing_site_enabled
enrollment_end_editable = GlobalStaff().has_user(request.user) or not marketing_site_enabled
short_description_editable = settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True)
+
+ self_paced_enabled = SelfPacedConfiguration.current().enabled
+
settings_context = {
'context_course': course_module,
'course_locator': course_key,
@@ -929,7 +933,8 @@ def settings_handler(request, course_key_string):
'show_min_grade_warning': False,
'enrollment_end_editable': enrollment_end_editable,
'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(),
- 'is_entrance_exams_enabled': is_entrance_exams_enabled()
+ 'is_entrance_exams_enabled': is_entrance_exams_enabled(),
+ 'self_paced_enabled': self_paced_enabled,
}
if is_prerequisite_courses_enabled():
courses, in_process_course_actions = get_courses_accessible_to_user(request)
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index c83fc475f0..ef2a0d8b89 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -10,6 +10,7 @@ from opaque_keys.edx.locations import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
from contentstore.utils import course_image_url, has_active_web_certificate
from models.settings import course_grading
+from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from xmodule.fields import Date
from xmodule.modulestore.django import modulestore
@@ -54,8 +55,7 @@ class CourseDetails(object):
'50'
) # minimum passing score for entrance exam content module/tree,
self.has_cert_config = None # course has active certificate configuration
- if settings.FEATURES.get('ENABLE_SELF_PACED_COURSES'):
- self.self_paced = None
+ self.self_paced = None
@classmethod
def _fetch_about_attribute(cls, course_key, attribute):
@@ -88,8 +88,7 @@ class CourseDetails(object):
# Default course license is "All Rights Reserved"
course_details.license = getattr(descriptor, "license", "all-rights-reserved")
course_details.has_cert_config = has_active_web_certificate(descriptor)
- if settings.FEATURES.get('ENABLE_SELF_PACED_COURSES'):
- course_details.self_paced = descriptor.self_paced
+ course_details.self_paced = descriptor.self_paced
for attribute in ABOUT_ATTRIBUTES:
value = cls._fetch_about_attribute(course_key, attribute)
@@ -192,7 +191,7 @@ class CourseDetails(object):
descriptor.language = jsondict['language']
dirty = True
- if (settings.FEATURES.get('ENABLE_SELF_PACED_COURSES')
+ if (SelfPacedConfiguration.current().enabled
and 'self_paced' in jsondict
and jsondict['self_paced'] != descriptor.self_paced):
descriptor.self_paced = jsondict['self_paced']
diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py
index 8fa79caea5..817eedac89 100644
--- a/cms/envs/bok_choy.py
+++ b/cms/envs/bok_choy.py
@@ -106,9 +106,6 @@ FEATURES['ENTRANCE_EXAMS'] = True
FEATURES['ENABLE_PROCTORED_EXAMS'] = True
-# Enable self-paced courses
-FEATURES['ENABLE_SELF_PACED_COURSES'] = True
-
# Point the URL used to test YouTube availability to our stub YouTube server
YOUTUBE_PORT = 9080
YOUTUBE['API'] = "http://127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index f5546b3238..1c0cb10634 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -182,9 +182,6 @@ FEATURES = {
# Timed or Proctored Exams
'ENABLE_PROCTORED_EXAMS': False,
-
- # Enable self-paced courses.
- 'ENABLE_SELF_PACED_COURSES': False,
}
ENABLE_JASMINE = False
@@ -796,6 +793,9 @@ INSTALLED_APPS = (
# programs support
'openedx.core.djangoapps.programs',
+
+ # Self-paced course configuration
+ 'openedx.core.djangoapps.self_paced',
)
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 159fbd63c5..b96e0c02b3 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -279,6 +279,3 @@ FEATURES['ENABLE_TEAMS'] = True
# Dummy secret key for dev/test
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
-
-# Enable self-paced courses
-FEATURES['ENABLE_SELF_PACED_COURSES'] = True
diff --git a/cms/templates/settings.html b/cms/templates/settings.html
index 570c4371a5..62a44799ac 100644
--- a/cms/templates/settings.html
+++ b/cms/templates/settings.html
@@ -412,7 +412,7 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
% endif
- % if settings.FEATURES.get("ENABLE_SELF_PACED_COURSES", False):
+ % if self_paced_enabled:
diff --git a/common/test/acceptance/tests/studio/test_studio_outline.py b/common/test/acceptance/tests/studio/test_studio_outline.py
index 76c8e7b1af..987480b67e 100644
--- a/common/test/acceptance/tests/studio/test_studio_outline.py
+++ b/common/test/acceptance/tests/studio/test_studio_outline.py
@@ -14,6 +14,7 @@ from ...pages.studio.utils import add_discussion, drag, verify_ordering
from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.course_nav import CourseNavPage
from ...pages.lms.staff_view import StaffPage
+from ...fixtures.config import ConfigModelFixture
from ...fixtures.course import XBlockFixtureDesc
from base_studio_test import StudioCourseTest
@@ -1766,6 +1767,7 @@ class SelfPacedOutlineTest(CourseOutlineTest):
),
)
self.course_fixture.add_course_details({'self_paced': True})
+ ConfigModelFixture('/config/self_paced', {'enabled': True}).install()
def test_release_dates_not_shown(self):
"""
diff --git a/common/test/acceptance/tests/studio/test_studio_settings_details.py b/common/test/acceptance/tests/studio/test_studio_settings_details.py
index 6cff8d6225..a193cfbcb1 100644
--- a/common/test/acceptance/tests/studio/test_studio_settings_details.py
+++ b/common/test/acceptance/tests/studio/test_studio_settings_details.py
@@ -4,6 +4,7 @@ Acceptance tests for Studio's Settings Details pages
from unittest import skip
from .base_studio_test import StudioCourseTest
+from ...fixtures.config import ConfigModelFixture
from ...fixtures.course import CourseFixture
from ...pages.studio.settings import SettingsPage
from ...pages.studio.overview import CourseOutlinePage
@@ -202,6 +203,9 @@ class SettingsMilestonesTest(StudioSettingsDetailsTest):
class CoursePacingTest(StudioSettingsDetailsTest):
"""Tests for setting a course to self-paced."""
+ def populate_course_fixture(self, __):
+ ConfigModelFixture('/config/self_paced', {'enabled': True}).install()
+
def test_default_instructor_led(self):
"""
Test that the 'instructor led' button is checked by default.
diff --git a/lms/djangoapps/courseware/self_paced_overrides.py b/lms/djangoapps/courseware/self_paced_overrides.py
index abd7b389c2..e41e4376f0 100644
--- a/lms/djangoapps/courseware/self_paced_overrides.py
+++ b/lms/djangoapps/courseware/self_paced_overrides.py
@@ -4,6 +4,7 @@ dates for each block in the course.
"""
from .field_overrides import FieldOverrideProvider
+from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
class SelfPacedDateOverrideProvider(FieldOverrideProvider):
@@ -20,4 +21,4 @@ class SelfPacedDateOverrideProvider(FieldOverrideProvider):
@classmethod
def enabled_for(cls, course):
"""This provider is enabled for self-paced courses only."""
- return course.self_paced
+ return SelfPacedConfiguration.current().enabled and course.self_paced
diff --git a/lms/djangoapps/courseware/tests/test_course_info.py b/lms/djangoapps/courseware/tests/test_course_info.py
index 6d1f3b63fe..3d1f18b3de 100644
--- a/lms/djangoapps/courseware/tests/test_course_info.py
+++ b/lms/djangoapps/courseware/tests/test_course_info.py
@@ -7,12 +7,13 @@ from urllib import urlencode
from django.conf import settings
from django.core.urlresolvers import reverse
+from django.test.utils import override_settings
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from util.date_utils import strftime_localized
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_CLOSED_MODULESTORE
-from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from student.models import CourseEnrollment
from .helpers import LoginEnrollmentTestCase
@@ -114,3 +115,34 @@ class CourseInfoTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertNotIn(self.xml_data, resp.content)
+
+
+@attr('shard_1')
+@override_settings(FEATURES=dict(settings.FEATURES, EMBARGO=False))
+class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
+ """
+ Tests for the info page of self-paced courses.
+ """
+
+ def setUp(self):
+ super(SelfPacedCourseInfoTestCase, self).setUp()
+ self.instructor_led_course = CourseFactory.create(self_paced=False)
+ self.self_paced_course = CourseFactory.create(self_paced=True)
+ self.setup_user()
+
+ def fetch_course_info_with_queries(self, course, sql_queries, mongo_queries):
+ """
+ Fetch the given course's info page, asserting the number of SQL
+ and Mongo queries.
+ """
+ url = reverse('info', args=[unicode(course.id)])
+ with self.assertNumQueries(sql_queries):
+ with check_mongo_calls(mongo_queries):
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+
+ def test_num_queries_instructor_led(self):
+ self.fetch_course_info_with_queries(self.instructor_led_course, 14, 4)
+
+ def test_num_queries_self_paced(self):
+ self.fetch_course_info_with_queries(self.self_paced_course, 14, 4)
diff --git a/lms/djangoapps/courseware/tests/test_self_paced_overrides.py b/lms/djangoapps/courseware/tests/test_self_paced_overrides.py
index e0df82b6e6..7142927e1e 100644
--- a/lms/djangoapps/courseware/tests/test_self_paced_overrides.py
+++ b/lms/djangoapps/courseware/tests/test_self_paced_overrides.py
@@ -9,6 +9,7 @@ from django.test.utils import override_settings
from student.tests.factories import UserFactory
from lms.djangoapps.ccx.tests.test_overrides import inject_field_overrides
from lms.djangoapps.courseware.field_overrides import OverrideFieldData
+from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
@@ -22,10 +23,9 @@ class SelfPacedDateOverrideTest(ModuleStoreTestCase):
"""
def setUp(self):
+ SelfPacedConfiguration(enabled=True).save()
super(SelfPacedDateOverrideTest, self).setUp()
self.due_date = datetime(2015, 5, 26, 8, 30, 00).replace(tzinfo=tzutc())
- self.instructor_led_course, self.il_section = self.setup_course("Instructor Led Course", False)
- self.self_paced_course, self.sp_section = self.setup_course("Self-Paced Course", True)
def tearDown(self):
super(SelfPacedDateOverrideTest, self).tearDown()
@@ -43,7 +43,14 @@ class SelfPacedDateOverrideTest(ModuleStoreTestCase):
return (course, section)
def test_instructor_led(self):
- self.assertEqual(self.due_date, self.il_section.due)
+ __, il_section = self.setup_course("Instructor Led Course", False)
+ self.assertEqual(self.due_date, il_section.due)
def test_self_paced(self):
- self.assertIsNone(self.sp_section.due)
+ __, sp_section = self.setup_course("Self-Paced Course", True)
+ self.assertIsNone(sp_section.due)
+
+ def test_self_paced_disabled(self):
+ SelfPacedConfiguration(enabled=False).save()
+ __, sp_section = self.setup_course("Self-Paced Course", True)
+ self.assertEqual(self.due_date, sp_section.due)
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 3d3008c223..f8bb36ed9c 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -677,10 +677,9 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'):
)
##### Self-Paced Course Due Dates #####
-if FEATURES.get('ENABLE_SELF_PACED_COURSES'):
- FIELD_OVERRIDE_PROVIDERS += (
- 'courseware.self_paced_overrides.SelfPacedDateOverrideProvider',
- )
+FIELD_OVERRIDE_PROVIDERS += (
+ 'courseware.self_paced_overrides.SelfPacedDateOverrideProvider',
+)
# PROFILE IMAGE CONFIG
PROFILE_IMAGE_BACKEND = ENV_TOKENS.get('PROFILE_IMAGE_BACKEND', PROFILE_IMAGE_BACKEND)
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index eee77ded80..4a4611f1dc 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -131,9 +131,6 @@ FEATURES['LICENSING'] = True
# Use the auto_auth workflow for creating users and logging them in
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
-# Enable self-paced courses
-FEATURES['ENABLE_SELF_PACED_COURSES'] = True
-
########################### Entrance Exams #################################
FEATURES['MILESTONES_APP'] = True
FEATURES['ENTRANCE_EXAMS'] = True
diff --git a/lms/envs/common.py b/lms/envs/common.py
index b338ffcb85..b3eba55b83 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -408,9 +408,6 @@ FEATURES = {
# Enable LTI Provider feature.
'ENABLE_LTI_PROVIDER': False,
-
- # Enable self-paced courses.
- 'ENABLE_SELF_PACED_COURSES': False,
}
# Ignore static asset files on import which match this pattern
@@ -1970,6 +1967,9 @@ INSTALLED_APPS = (
# programs support
'openedx.core.djangoapps.programs',
+
+ # Self-paced course configuration
+ 'openedx.core.djangoapps.self_paced',
)
######################### CSRF #########################################
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 6d2f46bba9..0ed5986cf7 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -532,6 +532,3 @@ AUTHENTICATION_BACKENDS += ('lti_provider.users.LtiBackend',)
# ORGANIZATIONS
FEATURES['ORGANIZATIONS_APP'] = True
-
-# Enable self-paced courses
-FEATURES['ENABLE_SELF_PACED_COURSES'] = True
diff --git a/lms/urls.py b/lms/urls.py
index 6ef2f67cdb..e43f2f8522 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -11,6 +11,9 @@ import django.contrib.auth.views
from microsite_configuration import microsite
import auth_exchange.views
+from config_models.views import ConfigurationModelCurrentAPIView
+from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
+
# Uncomment the next two lines to enable the admin:
if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
admin.autodiscover()
@@ -739,6 +742,10 @@ if settings.FEATURES.get("ENABLE_LTI_PROVIDER"):
url(r'^lti_provider/', include('lti_provider.urls')),
)
+urlpatterns += (
+ url(r'config/self_paced', ConfigurationModelCurrentAPIView.as_view(model=SelfPacedConfiguration)),
+)
+
urlpatterns = patterns(*urlpatterns)
if settings.DEBUG:
diff --git a/openedx/core/djangoapps/self_paced/__init__.py b/openedx/core/djangoapps/self_paced/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/self_paced/admin.py b/openedx/core/djangoapps/self_paced/admin.py
new file mode 100644
index 0000000000..ae85d9343f
--- /dev/null
+++ b/openedx/core/djangoapps/self_paced/admin.py
@@ -0,0 +1,10 @@
+"""
+Admin site bindings for self-paced courses.
+"""
+
+from django.contrib import admin
+
+from config_models.admin import ConfigurationModelAdmin
+from .models import SelfPacedConfiguration
+
+admin.site.register(SelfPacedConfiguration, ConfigurationModelAdmin)
diff --git a/openedx/core/djangoapps/self_paced/migrations/0001_initial.py b/openedx/core/djangoapps/self_paced/migrations/0001_initial.py
new file mode 100644
index 0000000000..f683ec1115
--- /dev/null
+++ b/openedx/core/djangoapps/self_paced/migrations/0001_initial.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding model 'SelfPacedConfiguration'
+ db.create_table('self_paced_selfpacedconfiguration', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
+ ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
+ ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
+ ))
+ db.send_create_signal('self_paced', ['SelfPacedConfiguration'])
+
+
+ def backwards(self, orm):
+ # Deleting model 'SelfPacedConfiguration'
+ db.delete_table('self_paced_selfpacedconfiguration')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'self_paced.selfpacedconfiguration': {
+ 'Meta': {'ordering': "('-change_date',)", 'object_name': 'SelfPacedConfiguration'},
+ 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
+ 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ }
+ }
+
+ complete_apps = ['self_paced']
\ No newline at end of file
diff --git a/openedx/core/djangoapps/self_paced/migrations/__init__.py b/openedx/core/djangoapps/self_paced/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/self_paced/models.py b/openedx/core/djangoapps/self_paced/models.py
new file mode 100644
index 0000000000..f76974dbda
--- /dev/null
+++ b/openedx/core/djangoapps/self_paced/models.py
@@ -0,0 +1,12 @@
+"""
+Configuration for self-paced courses.
+"""
+
+from config_models.models import ConfigurationModel
+
+
+class SelfPacedConfiguration(ConfigurationModel):
+ """
+ Configuration for self-paced courses.
+ """
+ pass
From 505b2aa4d9d4544e038dddadf6dcc15daa2e0409 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Fri, 23 Oct 2015 15:27:40 -0400
Subject: [PATCH 13/13] Disable setting course pacing during course run.
Also adds improved styling for course pacing settings, and unit tests
around query counts for self-paced courses.
ECOM-2650
---
.../tests/test_course_settings.py | 28 +++++++++++---
.../models/settings/course_details.py | 1 +
.../js/models/settings/course_details.js | 8 ++++
.../js/views/pages/container_subviews.js | 3 +-
cms/static/js/views/settings/main.js | 21 +++++++---
cms/static/js/views/validation.js | 1 +
cms/static/sass/views/_settings.scss | 25 ++++++++++++
cms/templates/js/publish-xblock.underscore | 32 ++++++++--------
cms/templates/settings.html | 17 +++++++--
common/lib/xmodule/xmodule/course_module.py | 10 +++++
.../test/acceptance/pages/studio/settings.py | 28 ++++++++++++--
.../tests/studio/test_studio_outline.py | 5 ++-
.../studio/test_studio_settings_details.py | 20 +++++++++-
.../courseware/self_paced_overrides.py | 4 ++
lms/djangoapps/courseware/tests/test_views.py | 38 ++++++++++++++++++-
lms/djangoapps/courseware/testutils.py | 9 ++++-
16 files changed, 210 insertions(+), 40 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 5b65ccbf44..ee7a6644cd 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -116,11 +116,21 @@ class CourseDetailsTestCase(CourseTestCase):
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).effort,
jsondetails.effort, "After set effort"
)
+ jsondetails.self_paced = True
+ self.assertEqual(
+ CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).self_paced,
+ jsondetails.self_paced
+ )
jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC())
self.assertEqual(
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).start_date,
jsondetails.start_date
)
+ jsondetails.end_date = datetime.datetime(2011, 10, 1, 0, tzinfo=UTC())
+ self.assertEqual(
+ CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).end_date,
+ jsondetails.end_date
+ )
jsondetails.course_image_name = "an_image.jpg"
self.assertEqual(
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).course_image_name,
@@ -131,11 +141,6 @@ class CourseDetailsTestCase(CourseTestCase):
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).language,
jsondetails.language
)
- jsondetails.self_paced = True
- self.assertEqual(
- CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).self_paced,
- jsondetails.self_paced
- )
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
@@ -291,6 +296,19 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertContains(response, "Course Introduction Video")
self.assertContains(response, "Requirements")
+ def test_toggle_pacing_during_course_run(self):
+ SelfPacedConfiguration(enabled=True).save()
+ self.course.start = datetime.datetime.now()
+ modulestore().update_item(self.course, self.user.id)
+
+ details = CourseDetails.fetch(self.course.id)
+ updated_details = CourseDetails.update_from_json(
+ self.course.id,
+ dict(details.__dict__, self_paced=True),
+ self.user
+ )
+ self.assertFalse(updated_details.self_paced)
+
@ddt.ddt
class CourseDetailsViewTest(CourseTestCase):
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index ef2a0d8b89..f09ec7b1a3 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -192,6 +192,7 @@ class CourseDetails(object):
dirty = True
if (SelfPacedConfiguration.current().enabled
+ and descriptor.can_toggle_course_pacing
and 'self_paced' in jsondict
and jsondict['self_paced'] != descriptor.self_paced):
descriptor.self_paced = jsondict['self_paced']
diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js
index efb3e24dcd..9703977314 100644
--- a/cms/static/js/models/settings/course_details.js
+++ b/cms/static/js/models/settings/course_details.js
@@ -70,6 +70,7 @@ var CourseDetails = Backbone.Model.extend({
},
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
+
set_videosource: function(newsource) {
// newsource either is or just the "speed:key, *" string
// returns the videosource for the preview which iss the key whose speed is closest to 1
@@ -81,9 +82,16 @@ var CourseDetails = Backbone.Model.extend({
return this.videosourceSample();
},
+
videosourceSample : function() {
if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video');
else return "";
+ },
+
+ // Whether or not the course pacing can be toggled. If the course
+ // has already started, returns false; otherwise, returns true.
+ canTogglePace: function () {
+ return new Date() <= new Date(this.get('start_date'));
}
});
diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js
index f4d7178ad9..b62ca4436c 100644
--- a/cms/static/js/views/pages/container_subviews.js
+++ b/cms/static/js/views/pages/container_subviews.js
@@ -115,7 +115,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
releaseDateFrom: this.model.get('release_date_from'),
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
staffLockFrom: this.model.get('staff_lock_from'),
- hasContentGroupComponents: this.model.get('has_content_group_components')
+ hasContentGroupComponents: this.model.get('has_content_group_components'),
+ course: window.course,
}));
return this;
diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js
index 1188c2b6eb..f730afaaa5 100644
--- a/cms/static/js/views/settings/main.js
+++ b/cms/static/js/views/settings/main.js
@@ -1,8 +1,9 @@
define(["js/views/validation", "codemirror", "underscore", "jquery", "jquery.ui", "js/utils/date_utils", "js/models/uploads",
"js/views/uploads", "js/utils/change_on_enter", "js/views/license", "js/models/license",
- "common/js/components/views/feedback_notification", "jquery.timepicker", "date"],
- function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
- FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel, NotificationView) {
+ "common/js/components/views/feedback_notification", "jquery.timepicker", "date", "gettext"],
+ function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
+ FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel, NotificationView,
+ timepicker, date, gettext) {
var DetailsView = ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseDetails
@@ -99,11 +100,19 @@ var DetailsView = ValidatingView.extend({
}
this.$('#' + this.fieldToSelectorMap['entrance_exam_minimum_score_pct']).val(this.model.get('entrance_exam_minimum_score_pct'));
- if (this.model.get('self_paced')) {
- this.$('#course-pace-self-paced').attr('checked', true);
+ var selfPacedButton = this.$('#course-pace-self-paced'),
+ instructorLedButton = this.$('#course-pace-instructor-led'),
+ paceToggleTip = this.$('#course-pace-toggle-tip');
+ (this.model.get('self_paced') ? selfPacedButton : instructorLedButton).attr('checked', true);
+ if (this.model.canTogglePace()) {
+ selfPacedButton.removeAttr('disabled');
+ instructorLedButton.removeAttr('disabled');
+ paceToggleTip.text('');
}
else {
- this.$('#course-pace-instructor-led').attr('checked', true);
+ selfPacedButton.attr('disabled', true);
+ instructorLedButton.attr('disabled', true);
+ paceToggleTip.text(gettext('Course pacing cannot be changed once a course has started.'));
}
this.licenseView.render()
diff --git a/cms/static/js/views/validation.js b/cms/static/js/views/validation.js
index a15505b7b4..d0b21e9810 100644
--- a/cms/static/js/views/validation.js
+++ b/cms/static/js/views/validation.js
@@ -157,6 +157,7 @@ var ValidatingView = BaseView.extend({
{
success: function() {
self.showSavedBar();
+ self.render();
},
silent: true
}
diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss
index 8be7e82dd5..bd808b98a0 100644
--- a/cms/static/sass/views/_settings.scss
+++ b/cms/static/sass/views/_settings.scss
@@ -1039,4 +1039,29 @@
}
}
}
+
+ // UI: course pacing options
+ .group-settings.pacing {
+ .list-input {
+ margin-top: $baseline/2;
+ background-color: $gray-l4;
+ border-radius: 3px;
+ padding: ($baseline/2);
+ }
+
+ .field {
+ @include margin(0, 0, $baseline, 0);
+
+ .field-radio {
+ display: inline-block;
+ @include margin-right($baseline/4);
+ width: auto;
+ height: auto;
+
+ & + .course-pace-label {
+ display: inline-block;
+ }
+ }
+ }
+ }
}
diff --git a/cms/templates/js/publish-xblock.underscore b/cms/templates/js/publish-xblock.underscore
index 6fcf7827cd..8afcafa364 100644
--- a/cms/templates/js/publish-xblock.underscore
+++ b/cms/templates/js/publish-xblock.underscore
@@ -42,22 +42,24 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
-
-
<%= releaseLabel %>
-
- <% if (releaseDate) { %>
- <%= releaseDate %>
-
- <%= interpolate(
- gettext('with %(release_date_from)s'), { release_date_from: releaseDateFrom }, true
- ) %>
-
+ <% if (!course.get('self_paced')) { %>
+
+
<%= releaseLabel %>
+
+ <% if (releaseDate) { %>
+ <%= releaseDate %>
+
+ <%= interpolate(
+ gettext('with %(release_date_from)s'), { release_date_from: releaseDateFrom }, true
+ ) %>
+
- <% } else { %>
- <%= gettext("Unscheduled") %>
- <% } %>
-
-
+ <% } else { %>
+ <%= gettext("Unscheduled") %>
+ <% } %>
+
+
+ <% } %>
diff --git a/cms/templates/settings.html b/cms/templates/settings.html
index 62a44799ac..b9456eb036 100644
--- a/cms/templates/settings.html
+++ b/cms/templates/settings.html
@@ -421,11 +421,20 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
${_("Course Pacing")}
${_("Set the pacing for this course")}
+
-
- Self-Paced
-
- Instructor Led
+
+
+
+ Instructor-Led
+ ${_("Instructor-led courses progress at the pace that the course author sets. You can configure release dates for course content and due dates for assignments.")}
+
+
+
+ Self-Paced
+ ${_("Self-paced courses do not have release dates for course content or due dates for assignments. Learners can complete course material at any time before the course end date.")}
+
+
% endif
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index 1b52885361..8487a95f47 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -1584,3 +1584,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
if p.scheme != scheme
]
self.user_partitions = other_partitions + partitions # pylint: disable=attribute-defined-outside-init
+
+ @property
+ def can_toggle_course_pacing(self):
+ """
+ Whether or not the course can be set to self-paced at this time.
+
+ Returns:
+ bool: False if the course has already started, True otherwise.
+ """
+ return datetime.now(UTC()) <= self.start
diff --git a/common/test/acceptance/pages/studio/settings.py b/common/test/acceptance/pages/studio/settings.py
index f7fd434d97..288a089c56 100644
--- a/common/test/acceptance/pages/studio/settings.py
+++ b/common/test/acceptance/pages/studio/settings.py
@@ -131,15 +131,20 @@ class SettingsPage(CoursePage):
raise Exception("Invalid license name: {name}".format(name=license_name))
button.click()
- pacing_css = 'section.pacing input[type=radio]:checked'
+ pacing_css = 'section.pacing input[type=radio]'
+
+ @property
+ def checked_pacing_css(self):
+ """CSS for the course pacing button which is currently checked."""
+ return self.pacing_css + ':checked'
@property
def course_pacing(self):
"""
Returns the label text corresponding to the checked pacing radio button.
"""
- self.wait_for_element_presence(self.pacing_css, 'course pacing controls present and rendered')
- checked = self.q(css=self.pacing_css).results[0]
+ self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present and rendered')
+ checked = self.q(css=self.checked_pacing_css).results[0]
checked_id = checked.get_attribute('id')
return self.q(css='label[for={checked_id}]'.format(checked_id=checked_id)).results[0].text
@@ -149,9 +154,24 @@ class SettingsPage(CoursePage):
Sets the course to either self-paced or instructor-led by checking
the appropriate radio button.
"""
- self.wait_for_element_presence(self.pacing_css, 'course pacing controls present')
+ self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present')
self.q(xpath="//label[contains(text(), '{pacing}')]".format(pacing=pacing)).click()
+ @property
+ def course_pacing_disabled_text(self):
+ """
+ Return the message indicating that course pacing cannot be toggled.
+ """
+ return self.q(css='#course-pace-toggle-tip').results[0].text
+
+ def course_pacing_disabled(self):
+ """
+ Return True if the course pacing controls are disabled; False otherwise.
+ """
+ self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present')
+ statuses = self.q(css=self.pacing_css).map(lambda e: e.get_attribute('disabled')).results
+ return all((s == 'true' for s in statuses))
+
################
# Waits
################
diff --git a/common/test/acceptance/tests/studio/test_studio_outline.py b/common/test/acceptance/tests/studio/test_studio_outline.py
index 987480b67e..8cd1935910 100644
--- a/common/test/acceptance/tests/studio/test_studio_outline.py
+++ b/common/test/acceptance/tests/studio/test_studio_outline.py
@@ -1766,7 +1766,10 @@ class SelfPacedOutlineTest(CourseOutlineTest):
)
),
)
- self.course_fixture.add_course_details({'self_paced': True})
+ self.course_fixture.add_course_details({
+ 'self_paced': True,
+ 'start_date': datetime.now() + timedelta(days=1)
+ })
ConfigModelFixture('/config/self_paced', {'enabled': True}).install()
def test_release_dates_not_shown(self):
diff --git a/common/test/acceptance/tests/studio/test_studio_settings_details.py b/common/test/acceptance/tests/studio/test_studio_settings_details.py
index a193cfbcb1..72a9793543 100644
--- a/common/test/acceptance/tests/studio/test_studio_settings_details.py
+++ b/common/test/acceptance/tests/studio/test_studio_settings_details.py
@@ -1,6 +1,7 @@
"""
Acceptance tests for Studio's Settings Details pages
"""
+from datetime import datetime, timedelta
from unittest import skip
from .base_studio_test import StudioCourseTest
@@ -205,19 +206,23 @@ class CoursePacingTest(StudioSettingsDetailsTest):
def populate_course_fixture(self, __):
ConfigModelFixture('/config/self_paced', {'enabled': True}).install()
+ # Set the course start date to tomorrow in order to allow setting pacing
+ self.course_fixture.add_course_details({'start_date': datetime.now() + timedelta(days=1)})
def test_default_instructor_led(self):
"""
Test that the 'instructor led' button is checked by default.
"""
- self.assertEqual(self.settings_detail.course_pacing, 'Instructor Led')
+ self.assertEqual(self.settings_detail.course_pacing, 'Instructor-Led')
def test_self_paced(self):
"""
Test that the 'self-paced' button is checked for a self-paced
course.
"""
- self.course_fixture.add_course_details({'self_paced': True})
+ self.course_fixture.add_course_details({
+ 'self_paced': True
+ })
self.course_fixture.configure_course()
self.settings_detail.refresh_page()
self.assertEqual(self.settings_detail.course_pacing, 'Self-Paced')
@@ -230,3 +235,14 @@ class CoursePacingTest(StudioSettingsDetailsTest):
self.settings_detail.save_changes()
self.settings_detail.refresh_page()
self.assertEqual(self.settings_detail.course_pacing, 'Self-Paced')
+
+ def test_toggle_pacing_after_course_start(self):
+ """
+ Test that course authors cannot toggle the pacing of their course
+ while the course is running.
+ """
+ self.course_fixture.add_course_details({'start_date': datetime.now()})
+ self.course_fixture.configure_course()
+ self.settings_detail.refresh_page()
+ self.assertTrue(self.settings_detail.course_pacing_disabled())
+ self.assertIn('Course pacing cannot be changed', self.settings_detail.course_pacing_disabled_text)
diff --git a/lms/djangoapps/courseware/self_paced_overrides.py b/lms/djangoapps/courseware/self_paced_overrides.py
index e41e4376f0..c38b8961a6 100644
--- a/lms/djangoapps/courseware/self_paced_overrides.py
+++ b/lms/djangoapps/courseware/self_paced_overrides.py
@@ -14,8 +14,12 @@ class SelfPacedDateOverrideProvider(FieldOverrideProvider):
due dates to be overridden for self-paced courses.
"""
def get(self, block, name, default):
+ # Remove due dates
if name == 'due':
return None
+ # Remove release dates for course content
+ if name == 'start' and block.category != 'course':
+ return None
return default
@classmethod
diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py
index afbcc99afe..778b55fe15 100644
--- a/lms/djangoapps/courseware/tests/test_views.py
+++ b/lms/djangoapps/courseware/tests/test_views.py
@@ -6,6 +6,7 @@ import cgi
from urllib import urlencode
import ddt
import json
+import itertools
import unittest
from datetime import datetime
from HTMLParser import HTMLParser
@@ -36,6 +37,7 @@ from courseware.testutils import RenderXBlockTestMixin
from courseware.tests.factories import StudentModuleFactory
from courseware.user_state_client import DjangoXBlockUserStateClient
from edxmako.tests import mako_middleware_process_request
+from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from student.models import CourseEnrollment
from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory
from util.tests.test_date_utils import fake_ugettext, fake_pgettext
@@ -45,7 +47,7 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
@attr('shard_1')
@@ -669,10 +671,18 @@ class ProgressPageTests(ModuleStoreTestCase):
self.request.user = self.user
mako_middleware_process_request(self.request)
+
+ self.setup_course()
+
+ def setup_course(self, **options):
+ """Create the test course."""
course = CourseFactory.create(
start=datetime(2013, 9, 16, 7, 17, 28),
grade_cutoffs={u'çü†øƒƒ': 0.75, 'Pass': 0.5},
+ **options
)
+
+ # pylint: disable=attribute-defined-outside-init
self.course = modulestore().get_course(course.id)
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
@@ -829,6 +839,18 @@ class ProgressPageTests(ModuleStoreTestCase):
resp = views.progress(self.request, course_id=unicode(self.course.id))
self.assertContains(resp, u"Download Your Certificate")
+ @ddt.data(
+ *itertools.product(((18, 4, True), (18, 4, False)), (True, False))
+ )
+ @ddt.unpack
+ def test_query_counts(self, (sql_calls, mongo_calls, self_paced), self_paced_enabled):
+ """Test that query counts remain the same for self-paced and instructor-led courses."""
+ SelfPacedConfiguration(enabled=self_paced_enabled).save()
+ self.setup_course(self_paced=self_paced)
+ with self.assertNumQueries(sql_calls), check_mongo_calls(mongo_calls):
+ resp = views.progress(self.request, course_id=unicode(self.course.id))
+ self.assertEqual(resp.status_code, 200)
+
@attr('shard_1')
class VerifyCourseKeyDecoratorTests(TestCase):
@@ -1151,3 +1173,17 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase):
if url_encoded_params:
url += '?' + url_encoded_params
return self.client.get(url)
+
+
+class TestRenderXBlockSelfPaced(TestRenderXBlock):
+ """
+ Test rendering XBlocks for a self-paced course. Relies on the query
+ count assertions in the tests defined by RenderXBlockMixin.
+ """
+
+ def setUp(self):
+ super(TestRenderXBlockSelfPaced, self).setUp()
+ SelfPacedConfiguration(enabled=True).save()
+
+ def course_options(self):
+ return {'self_paced': True}
diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py
index 185c02a35f..194421d14b 100644
--- a/lms/djangoapps/courseware/testutils.py
+++ b/lms/djangoapps/courseware/testutils.py
@@ -55,6 +55,13 @@ class RenderXBlockTestMixin(object):
"""
self.client.login(username=self.user.username, password='test')
+ def course_options(self):
+ """
+ Options to configure the test course. Intended to be overridden by
+ subclasses.
+ """
+ return {}
+
def setup_course(self, default_store=None):
"""
Helper method to create the course.
@@ -62,7 +69,7 @@ class RenderXBlockTestMixin(object):
if not default_store:
default_store = self.store.default_modulestore.get_modulestore_type()
with self.store.default_store(default_store):
- self.course = CourseFactory.create() # pylint: disable=attribute-defined-outside-init
+ self.course = CourseFactory.create(**self.course_options()) # pylint: disable=attribute-defined-outside-init
chapter = ItemFactory.create(parent=self.course, category='chapter')
self.html_block = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
parent=chapter,