Merge pull request #10404 from edx/feature/self-paced
Enable self-paced courses.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -56,6 +57,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)
|
||||
@@ -86,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 = "<a href='foo'>bar</a>"
|
||||
# encode - decode to convert date fields and other data which changes form
|
||||
@@ -113,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,
|
||||
@@ -283,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):
|
||||
@@ -314,6 +340,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
|
||||
@@ -334,6 +361,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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,6 +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
|
||||
self.self_paced = None
|
||||
|
||||
@classmethod
|
||||
def _fetch_about_attribute(cls, course_key, attribute):
|
||||
@@ -86,6 +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)
|
||||
course_details.self_paced = descriptor.self_paced
|
||||
|
||||
for attribute in ABOUT_ATTRIBUTES:
|
||||
value = cls._fetch_about_attribute(course_key, attribute)
|
||||
@@ -188,6 +191,13 @@ class CourseDetails(object):
|
||||
descriptor.language = jsondict['language']
|
||||
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']
|
||||
dirty = True
|
||||
|
||||
if dirty:
|
||||
module_store.update_item(descriptor, user.id)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ class CourseMetadata(object):
|
||||
'is_proctored_enabled',
|
||||
'is_time_limited',
|
||||
'is_practice_exam',
|
||||
'self_paced'
|
||||
]
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -793,6 +793,9 @@ INSTALLED_APPS = (
|
||||
|
||||
# programs support
|
||||
'openedx.core.djangoapps.programs',
|
||||
|
||||
# Self-paced course configuration
|
||||
'openedx.core.djangoapps.self_paced',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ var CourseDetails = Backbone.Model.extend({
|
||||
},
|
||||
|
||||
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
|
||||
|
||||
set_videosource: function(newsource) {
|
||||
// newsource either is <video youtube="speed:key, *"/> 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'));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +100,21 @@ var DetailsView = ValidatingView.extend({
|
||||
}
|
||||
this.$('#' + this.fieldToSelectorMap['entrance_exam_minimum_score_pct']).val(this.model.get('entrance_exam_minimum_score_pct'));
|
||||
|
||||
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 {
|
||||
selfPacedButton.attr('disabled', true);
|
||||
instructorLedButton.attr('disabled', true);
|
||||
paceToggleTip.text(gettext('Course pacing cannot be changed once a course has started.'));
|
||||
}
|
||||
|
||||
this.licenseView.render()
|
||||
|
||||
return this;
|
||||
@@ -236,6 +252,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;
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ var ValidatingView = BaseView.extend({
|
||||
{
|
||||
success: function() {
|
||||
self.showSavedBar();
|
||||
self.render();
|
||||
},
|
||||
silent: true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,18 +113,20 @@ if (xblockInfo.get('graded')) {
|
||||
<p>
|
||||
<span class="sr status-release-label"><%= gettext('Release Status:') %></span>
|
||||
<span class="status-release-value">
|
||||
<% if (xblockInfo.get('released_to_students')) { %>
|
||||
<i class="icon fa fa-check-square-o"></i>
|
||||
<%= gettext('Released:') %>
|
||||
<% } else if (xblockInfo.get('release_date')) { %>
|
||||
<i class="icon fa fa-clock-o"></i>
|
||||
<%= gettext('Scheduled:') %>
|
||||
<% } else { %>
|
||||
<i class="icon fa fa-clock-o"></i>
|
||||
<%= gettext('Unscheduled') %>
|
||||
<% } %>
|
||||
<% if (xblockInfo.get('release_date')) { %>
|
||||
<%= xblockInfo.get('release_date') %>
|
||||
<% if (!course.get('self_paced')) { %>
|
||||
<% if (xblockInfo.get('released_to_students')) { %>
|
||||
<i class="icon fa fa-check-square-o"></i>
|
||||
<%= gettext('Released:') %>
|
||||
<% } else if (xblockInfo.get('release_date')) { %>
|
||||
<i class="icon fa fa-clock-o"></i>
|
||||
<%= gettext('Scheduled:') %>
|
||||
<% } else { %>
|
||||
<i class="icon fa fa-clock-o"></i>
|
||||
<%= gettext('Unscheduled') %>
|
||||
<% } %>
|
||||
<% if (xblockInfo.get('release_date')) { %>
|
||||
<%= xblockInfo.get('release_date') %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -42,22 +42,24 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-release bar-mod-content">
|
||||
<h5 class="title"><%= releaseLabel %></h5>
|
||||
<p class="copy">
|
||||
<% if (releaseDate) { %>
|
||||
<span class="release-date"><%= releaseDate %></span>
|
||||
<span class="release-with">
|
||||
<%= interpolate(
|
||||
gettext('with %(release_date_from)s'), { release_date_from: releaseDateFrom }, true
|
||||
) %>
|
||||
</span>
|
||||
<% if (!course.get('self_paced')) { %>
|
||||
<div class="wrapper-release bar-mod-content">
|
||||
<h5 class="title"><%= releaseLabel %></h5>
|
||||
<p class="copy">
|
||||
<% if (releaseDate) { %>
|
||||
<span class="release-date"><%= releaseDate %></span>
|
||||
<span class="release-with">
|
||||
<%= interpolate(
|
||||
gettext('with %(release_date_from)s'), { release_date_from: releaseDateFrom }, true
|
||||
) %>
|
||||
</span>
|
||||
|
||||
<% } else { %>
|
||||
<%= gettext("Unscheduled") %>
|
||||
<% } %>
|
||||
</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<%= gettext("Unscheduled") %>
|
||||
<% } %>
|
||||
</p>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="wrapper-visibility bar-mod-content">
|
||||
<h5 class="title">
|
||||
|
||||
@@ -412,6 +412,33 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
|
||||
</section>
|
||||
% endif
|
||||
|
||||
% if self_paced_enabled:
|
||||
|
||||
<hr class="divide" />
|
||||
|
||||
<section class="group-settings pacing">
|
||||
<header>
|
||||
<h2 class="title-2">${_("Course Pacing")}</h2>
|
||||
<span class="tip">${_("Set the pacing for this course")}</span>
|
||||
</header>
|
||||
<span class="msg" id="course-pace-toggle-tip"></span>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field">
|
||||
<input type="radio" class="field-radio" name="self-paced" id="course-pace-instructor-led" value="false"/>
|
||||
<label class="course-pace-label" for="course-pace-instructor-led">Instructor-Led</label>
|
||||
<span class="tip">${_("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.")}</span>
|
||||
</li>
|
||||
<li class="field">
|
||||
<input type="radio" class="field-radio" name="self-paced" id="course-pace-self-paced" value="true"/>
|
||||
<label class="course-pace-label" for="course-pace-self-paced">Self-Paced</label>
|
||||
<span class="tip">${_("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.")}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
% endif
|
||||
|
||||
% if settings.FEATURES.get("LICENSING", False):
|
||||
<hr class="divide" />
|
||||
|
||||
|
||||
@@ -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 # 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:
|
||||
|
||||
@@ -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, __):
|
||||
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,86 @@ 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):
|
||||
"""
|
||||
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',
|
||||
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
|
||||
__ = 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)
|
||||
# pylint: disable=no-member
|
||||
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)
|
||||
|
||||
50
common/djangoapps/config_models/views.py
Normal file
50
common/djangoapps/config_models/views.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
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
|
||||
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']
|
||||
|
||||
|
||||
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):
|
||||
"""Serializer class for configuration models."""
|
||||
class Meta(object):
|
||||
"""Meta information for AutoConfigModelSerializer."""
|
||||
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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
@@ -1573,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
95
common/test/acceptance/fixtures/config.py
Normal file
95
common/test/acceptance/fixtures/config.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Fixture to manipulate configuration models.
|
||||
"""
|
||||
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(r'Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
|
||||
r'(?P<username>\S+)', r'(?P<email>[^\)]+)', r'(?P<password>\S+)', r'(?P<user_id>\d+)'))
|
||||
user_matches = re.match(user_pattern, response.text)
|
||||
if user_matches:
|
||||
self.user = user_matches.groupdict() # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
return session
|
||||
|
||||
else:
|
||||
msg = "Could not log in to use ConfigModel restful API. Status code: {0}".format(response.status_code)
|
||||
raise ConfigModelFixureError(msg)
|
||||
@@ -131,6 +131,47 @@ class SettingsPage(CoursePage):
|
||||
raise Exception("Invalid license name: {name}".format(name=license_name))
|
||||
button.click()
|
||||
|
||||
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.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
|
||||
|
||||
@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.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
|
||||
################
|
||||
|
||||
@@ -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
|
||||
@@ -1752,3 +1753,57 @@ 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,
|
||||
'start_date': datetime.now() + timedelta(days=1)
|
||||
})
|
||||
ConfigModelFixture('/config/self_paced', {'enabled': True}).install()
|
||||
|
||||
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())
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
Acceptance tests for Studio's Settings Details pages
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
@@ -16,12 +18,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 +34,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 +199,50 @@ 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 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')
|
||||
|
||||
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')
|
||||
|
||||
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)
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
28
lms/djangoapps/courseware/self_paced_overrides.py
Normal file
28
lms/djangoapps/courseware/self_paced_overrides.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Field overrides for self-paced courses. This allows overriding due
|
||||
dates for each block in the course.
|
||||
"""
|
||||
|
||||
from .field_overrides import FieldOverrideProvider
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
|
||||
|
||||
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):
|
||||
# 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
|
||||
def enabled_for(cls, course):
|
||||
"""This provider is enabled for self-paced courses only."""
|
||||
return SelfPacedConfiguration.current().enabled and course.self_paced
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
56
lms/djangoapps/courseware/tests/test_self_paced_overrides.py
Normal file
56
lms/djangoapps/courseware/tests/test_self_paced_overrides.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
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 openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
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):
|
||||
SelfPacedConfiguration(enabled=True).save()
|
||||
super(SelfPacedDateOverrideTest, self).setUp()
|
||||
self.due_date = datetime(2015, 5, 26, 8, 30, 00).replace(tzinfo=tzutc())
|
||||
|
||||
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):
|
||||
__, il_section = self.setup_course("Instructor Led Course", False)
|
||||
self.assertEqual(self.due_date, il_section.due)
|
||||
|
||||
def test_self_paced(self):
|
||||
__, 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)
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -676,6 +676,11 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'):
|
||||
'courseware.student_field_overrides.IndividualStudentOverrideProvider',
|
||||
)
|
||||
|
||||
##### Self-Paced Course Due Dates #####
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1966,6 +1966,9 @@ INSTALLED_APPS = (
|
||||
|
||||
# programs support
|
||||
'openedx.core.djangoapps.programs',
|
||||
|
||||
# Self-paced course configuration
|
||||
'openedx.core.djangoapps.self_paced',
|
||||
)
|
||||
|
||||
######################### CSRF #########################################
|
||||
|
||||
@@ -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:
|
||||
|
||||
0
openedx/core/djangoapps/self_paced/__init__.py
Normal file
0
openedx/core/djangoapps/self_paced/__init__.py
Normal file
10
openedx/core/djangoapps/self_paced/admin.py
Normal file
10
openedx/core/djangoapps/self_paced/admin.py
Normal file
@@ -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)
|
||||
@@ -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']
|
||||
12
openedx/core/djangoapps/self_paced/models.py
Normal file
12
openedx/core/djangoapps/self_paced/models.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Configuration for self-paced courses.
|
||||
"""
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
|
||||
|
||||
class SelfPacedConfiguration(ConfigurationModel):
|
||||
"""
|
||||
Configuration for self-paced courses.
|
||||
"""
|
||||
pass
|
||||
Reference in New Issue
Block a user