${_("Set the pacing for this course")}
+
-
-
-
-
+
+
+
+
+ ${_("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 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,