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): + +
+ +
+
+

${_("Course Pacing")}

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