Commit d9751a85 by Peter Fogg

Merge pull request #10404 from edx/feature/self-paced

Enable self-paced courses.
parents e51dbc4d 505b2aa4
...@@ -30,6 +30,7 @@ from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY ...@@ -30,6 +30,7 @@ from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY
import ddt import ddt
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from util.milestones_helpers import seed_milestone_relationship_types from util.milestones_helpers import seed_milestone_relationship_types
...@@ -56,6 +57,7 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -56,6 +57,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort)) self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
self.assertIsNone(details.language, "language somehow initialized" + str(details.language)) self.assertIsNone(details.language, "language somehow initialized" + str(details.language))
self.assertIsNone(details.has_cert_config) self.assertIsNone(details.has_cert_config)
self.assertFalse(details.self_paced)
def test_encoder(self): def test_encoder(self):
details = CourseDetails.fetch(self.course.id) details = CourseDetails.fetch(self.course.id)
...@@ -86,6 +88,7 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -86,6 +88,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertEqual(jsondetails['string'], 'string') self.assertEqual(jsondetails['string'], 'string')
def test_update_and_fetch(self): def test_update_and_fetch(self):
SelfPacedConfiguration(enabled=True).save()
jsondetails = CourseDetails.fetch(self.course.id) jsondetails = CourseDetails.fetch(self.course.id)
jsondetails.syllabus = "<a href='foo'>bar</a>" jsondetails.syllabus = "<a href='foo'>bar</a>"
# encode - decode to convert date fields and other data which changes form # encode - decode to convert date fields and other data which changes form
...@@ -113,11 +116,21 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -113,11 +116,21 @@ class CourseDetailsTestCase(CourseTestCase):
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).effort, CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).effort,
jsondetails.effort, "After set 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()) jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC())
self.assertEqual( self.assertEqual(
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).start_date, CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).start_date,
jsondetails.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" jsondetails.course_image_name = "an_image.jpg"
self.assertEqual( self.assertEqual(
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).course_image_name, CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).course_image_name,
...@@ -283,6 +296,19 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -283,6 +296,19 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertContains(response, "Course Introduction Video") self.assertContains(response, "Course Introduction Video")
self.assertContains(response, "Requirements") 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 @ddt.ddt
class CourseDetailsViewTest(CourseTestCase): class CourseDetailsViewTest(CourseTestCase):
...@@ -314,6 +340,7 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -314,6 +340,7 @@ class CourseDetailsViewTest(CourseTestCase):
return Date().to_json(datetime_obj) return Date().to_json(datetime_obj)
def test_update_and_fetch(self): def test_update_and_fetch(self):
SelfPacedConfiguration(enabled=True).save()
details = CourseDetails.fetch(self.course.id) details = CourseDetails.fetch(self.course.id)
# resp s/b json from here on # resp s/b json from here on
...@@ -334,6 +361,7 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -334,6 +361,7 @@ class CourseDetailsViewTest(CourseTestCase):
self.alter_field(url, details, 'effort', "effort") self.alter_field(url, details, 'effort', "effort")
self.alter_field(url, details, 'course_image_name', "course_image_name") self.alter_field(url, details, 'course_image_name', "course_image_name")
self.alter_field(url, details, 'language', "en") self.alter_field(url, details, 'language', "en")
self.alter_field(url, details, 'self_paced', "true")
def compare_details_with_encoding(self, encoded, details, context): def compare_details_with_encoding(self, encoded, details, context):
""" """
......
...@@ -28,6 +28,7 @@ from openedx.core.lib.course_tabs import CourseTabPluginManager ...@@ -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.api import is_credit_course, get_credit_requirements
from openedx.core.djangoapps.credit.tasks import update_credit_course_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.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 import EdxJSONEncoder
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
...@@ -913,6 +914,9 @@ def settings_handler(request, course_key_string): ...@@ -913,6 +914,9 @@ def settings_handler(request, course_key_string):
about_page_editable = not marketing_site_enabled about_page_editable = not marketing_site_enabled
enrollment_end_editable = GlobalStaff().has_user(request.user) or 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) short_description_editable = settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True)
self_paced_enabled = SelfPacedConfiguration.current().enabled
settings_context = { settings_context = {
'context_course': course_module, 'context_course': course_module,
'course_locator': course_key, 'course_locator': course_key,
...@@ -929,7 +933,8 @@ def settings_handler(request, course_key_string): ...@@ -929,7 +933,8 @@ def settings_handler(request, course_key_string):
'show_min_grade_warning': False, 'show_min_grade_warning': False,
'enrollment_end_editable': enrollment_end_editable, 'enrollment_end_editable': enrollment_end_editable,
'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(), '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(): if is_prerequisite_courses_enabled():
courses, in_process_course_actions = get_courses_accessible_to_user(request) courses, in_process_course_actions = get_courses_accessible_to_user(request)
......
...@@ -10,6 +10,7 @@ from opaque_keys.edx.locations import Location ...@@ -10,6 +10,7 @@ from opaque_keys.edx.locations import Location
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from contentstore.utils import course_image_url, has_active_web_certificate from contentstore.utils import course_image_url, has_active_web_certificate
from models.settings import course_grading from models.settings import course_grading
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from xmodule.fields import Date from xmodule.fields import Date
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -54,6 +55,7 @@ class CourseDetails(object): ...@@ -54,6 +55,7 @@ class CourseDetails(object):
'50' '50'
) # minimum passing score for entrance exam content module/tree, ) # minimum passing score for entrance exam content module/tree,
self.has_cert_config = None # course has active certificate configuration self.has_cert_config = None # course has active certificate configuration
self.self_paced = None
@classmethod @classmethod
def _fetch_about_attribute(cls, course_key, attribute): def _fetch_about_attribute(cls, course_key, attribute):
...@@ -86,6 +88,7 @@ class CourseDetails(object): ...@@ -86,6 +88,7 @@ class CourseDetails(object):
# Default course license is "All Rights Reserved" # Default course license is "All Rights Reserved"
course_details.license = getattr(descriptor, "license", "all-rights-reserved") course_details.license = getattr(descriptor, "license", "all-rights-reserved")
course_details.has_cert_config = has_active_web_certificate(descriptor) course_details.has_cert_config = has_active_web_certificate(descriptor)
course_details.self_paced = descriptor.self_paced
for attribute in ABOUT_ATTRIBUTES: for attribute in ABOUT_ATTRIBUTES:
value = cls._fetch_about_attribute(course_key, attribute) value = cls._fetch_about_attribute(course_key, attribute)
...@@ -188,6 +191,13 @@ class CourseDetails(object): ...@@ -188,6 +191,13 @@ class CourseDetails(object):
descriptor.language = jsondict['language'] descriptor.language = jsondict['language']
dirty = True 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: if dirty:
module_store.update_item(descriptor, user.id) module_store.update_item(descriptor, user.id)
......
...@@ -50,6 +50,7 @@ class CourseMetadata(object): ...@@ -50,6 +50,7 @@ class CourseMetadata(object):
'is_proctored_enabled', 'is_proctored_enabled',
'is_time_limited', 'is_time_limited',
'is_practice_exam', 'is_practice_exam',
'self_paced'
] ]
@classmethod @classmethod
......
...@@ -793,6 +793,9 @@ INSTALLED_APPS = ( ...@@ -793,6 +793,9 @@ INSTALLED_APPS = (
# programs support # programs support
'openedx.core.djangoapps.programs', 'openedx.core.djangoapps.programs',
# Self-paced course configuration
'openedx.core.djangoapps.self_paced',
) )
......
...@@ -70,6 +70,7 @@ var CourseDetails = Backbone.Model.extend({ ...@@ -70,6 +70,7 @@ var CourseDetails = Backbone.Model.extend({
}, },
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g, _videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
set_videosource: function(newsource) { set_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string // 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 // 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({ ...@@ -81,9 +82,16 @@ var CourseDetails = Backbone.Model.extend({
return this.videosourceSample(); return this.videosourceSample();
}, },
videosourceSample : function() { videosourceSample : function() {
if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video'); if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video');
else return ""; 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', ...@@ -584,6 +584,10 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
editors.push(VerificationAccessEditor); editors.push(VerificationAccessEditor);
} }
} }
/* globals course */
if (course.get('self_paced')) {
editors = _.without(editors, ReleaseDateEditor, DueDateEditor);
}
return new SettingsXBlockModal($.extend({ return new SettingsXBlockModal($.extend({
editors: editors, editors: editors,
model: xblockInfo model: xblockInfo
......
...@@ -115,7 +115,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo ...@@ -115,7 +115,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
releaseDateFrom: this.model.get('release_date_from'), releaseDateFrom: this.model.get('release_date_from'),
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
staffLockFrom: this.model.get('staff_lock_from'), 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; return this;
......
define(["js/views/validation", "codemirror", "underscore", "jquery", "jquery.ui", "js/utils/date_utils", "js/models/uploads", 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", "js/views/uploads", "js/utils/change_on_enter", "js/views/license", "js/models/license",
"common/js/components/views/feedback_notification", "jquery.timepicker", "date"], "common/js/components/views/feedback_notification", "jquery.timepicker", "date", "gettext"],
function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel, function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel, NotificationView) { FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel, NotificationView,
timepicker, date, gettext) {
var DetailsView = ValidatingView.extend({ var DetailsView = ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseDetails // Model class is CMS.Models.Settings.CourseDetails
...@@ -99,6 +100,21 @@ var DetailsView = ValidatingView.extend({ ...@@ -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')); 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() this.licenseView.render()
return this; return this;
...@@ -236,6 +252,11 @@ var DetailsView = ValidatingView.extend({ ...@@ -236,6 +252,11 @@ var DetailsView = ValidatingView.extend({
} }
}, this), 1000); }, this), 1000);
break; 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. default: // Everything else is handled by datepickers and CodeMirror.
break; break;
} }
......
...@@ -157,6 +157,7 @@ var ValidatingView = BaseView.extend({ ...@@ -157,6 +157,7 @@ var ValidatingView = BaseView.extend({
{ {
success: function() { success: function() {
self.showSavedBar(); self.showSavedBar();
self.render();
}, },
silent: true silent: true
} }
......
...@@ -89,6 +89,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo ...@@ -89,6 +89,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
}, true); }, true);
defaultNewChildName = childInfo.display_name; defaultNewChildName = childInfo.display_name;
} }
/* globals course */
return { return {
xblockInfo: xblockInfo, xblockInfo: xblockInfo,
visibilityClass: XBlockViewUtils.getXBlockVisibilityClass(xblockInfo.get('visibility_state')), visibilityClass: XBlockViewUtils.getXBlockVisibilityClass(xblockInfo.get('visibility_state')),
...@@ -104,7 +105,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo ...@@ -104,7 +105,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
isCollapsed: isCollapsed, isCollapsed: isCollapsed,
includesChildren: this.shouldRenderChildren(), includesChildren: this.shouldRenderChildren(),
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), 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 @@ ...@@ -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 ...@@ -85,7 +85,8 @@ import json
org: "${context_course.location.org | h}", org: "${context_course.location.org | h}",
num: "${context_course.location.course | h}", num: "${context_course.location.course | h}",
display_course_number: "${_(context_course.display_coursenumber)}", 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 % endif
......
...@@ -113,18 +113,20 @@ if (xblockInfo.get('graded')) { ...@@ -113,18 +113,20 @@ if (xblockInfo.get('graded')) {
<p> <p>
<span class="sr status-release-label"><%= gettext('Release Status:') %></span> <span class="sr status-release-label"><%= gettext('Release Status:') %></span>
<span class="status-release-value"> <span class="status-release-value">
<% if (xblockInfo.get('released_to_students')) { %> <% if (!course.get('self_paced')) { %>
<i class="icon fa fa-check-square-o"></i> <% if (xblockInfo.get('released_to_students')) { %>
<%= gettext('Released:') %> <i class="icon fa fa-check-square-o"></i>
<% } else if (xblockInfo.get('release_date')) { %> <%= gettext('Released:') %>
<i class="icon fa fa-clock-o"></i> <% } else if (xblockInfo.get('release_date')) { %>
<%= gettext('Scheduled:') %> <i class="icon fa fa-clock-o"></i>
<% } else { %> <%= gettext('Scheduled:') %>
<i class="icon fa fa-clock-o"></i> <% } else { %>
<%= gettext('Unscheduled') %> <i class="icon fa fa-clock-o"></i>
<% } %> <%= gettext('Unscheduled') %>
<% if (xblockInfo.get('release_date')) { %> <% } %>
<%= xblockInfo.get('release_date') %> <% if (xblockInfo.get('release_date')) { %>
<%= xblockInfo.get('release_date') %>
<% } %>
<% } %> <% } %>
</span> </span>
</p> </p>
......
...@@ -42,22 +42,24 @@ var visibleToStaffOnly = visibilityState === 'staff_only'; ...@@ -42,22 +42,24 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
</p> </p>
</div> </div>
<div class="wrapper-release bar-mod-content"> <% if (!course.get('self_paced')) { %>
<h5 class="title"><%= releaseLabel %></h5> <div class="wrapper-release bar-mod-content">
<p class="copy"> <h5 class="title"><%= releaseLabel %></h5>
<% if (releaseDate) { %> <p class="copy">
<span class="release-date"><%= releaseDate %></span> <% if (releaseDate) { %>
<span class="release-with"> <span class="release-date"><%= releaseDate %></span>
<%= interpolate( <span class="release-with">
gettext('with %(release_date_from)s'), { release_date_from: releaseDateFrom }, true <%= interpolate(
) %> gettext('with %(release_date_from)s'), { release_date_from: releaseDateFrom }, true
</span> ) %>
</span>
<% } else { %> <% } else { %>
<%= gettext("Unscheduled") %> <%= gettext("Unscheduled") %>
<% } %> <% } %>
</p> </p>
</div> </div>
<% } %>
<div class="wrapper-visibility bar-mod-content"> <div class="wrapper-visibility bar-mod-content">
<h5 class="title"> <h5 class="title">
......
...@@ -412,6 +412,33 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; ...@@ -412,6 +412,33 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
</section> </section>
% endif % 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): % if settings.FEATURES.get("LICENSING", False):
<hr class="divide" /> <hr class="divide" />
......
...@@ -93,6 +93,8 @@ class ConfigurationModel(models.Model): ...@@ -93,6 +93,8 @@ class ConfigurationModel(models.Model):
""" """
Clear the cached value when saving a new configuration entry 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) super(ConfigurationModel, self).save(*args, **kwargs)
cache.delete(self.cache_key_name(*[getattr(self, key) for key in self.KEY_FIELDS])) cache.delete(self.cache_key_name(*[getattr(self, key) for key in self.KEY_FIELDS]))
if self.KEY_FIELDS: if self.KEY_FIELDS:
......
...@@ -7,10 +7,13 @@ import ddt ...@@ -7,10 +7,13 @@ import ddt
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.test import TestCase from django.test import TestCase
from rest_framework.test import APIRequestFactory
from freezegun import freeze_time from freezegun import freeze_time
from mock import patch from mock import patch, Mock
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
from config_models.views import ConfigurationModelCurrentAPIView
class ExampleConfig(ConfigurationModel): class ExampleConfig(ConfigurationModel):
...@@ -92,6 +95,14 @@ class ConfigurationModelTests(TestCase): ...@@ -92,6 +95,14 @@ class ConfigurationModelTests(TestCase):
self.assertEqual(rows[1].string_field, 'first') self.assertEqual(rows[1].string_field, 'first')
self.assertEqual(rows[1].is_active, False) 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): class ExampleKeyedConfig(ConfigurationModel):
""" """
...@@ -282,3 +293,86 @@ class KeyedConfigurationModelTests(TestCase): ...@@ -282,3 +293,86 @@ class KeyedConfigurationModelTests(TestCase):
fake_result = [('a', 'b'), ('c', 'd')] fake_result = [('a', 'b'), ('c', 'd')]
mock_cache.get.return_value = fake_result mock_cache.get.return_value = fake_result
self.assertEquals(ExampleKeyedConfig.key_values(), 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)
"""
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): ...@@ -1796,6 +1796,7 @@ def auto_auth(request):
email = request.GET.get('email', unique_name + "@example.com") email = request.GET.get('email', unique_name + "@example.com")
full_name = request.GET.get('full_name', username) full_name = request.GET.get('full_name', username)
is_staff = request.GET.get('staff', None) is_staff = request.GET.get('staff', None)
is_superuser = request.GET.get('superuser', None)
course_id = request.GET.get('course_id', None) course_id = request.GET.get('course_id', None)
# mode has to be one of 'honor'/'professional'/'verified'/'audit'/'no-id-professional'/'credit' # mode has to be one of 'honor'/'professional'/'verified'/'audit'/'no-id-professional'/'credit'
...@@ -1836,6 +1837,10 @@ def auto_auth(request): ...@@ -1836,6 +1837,10 @@ def auto_auth(request):
user.is_staff = (is_staff == "true") user.is_staff = (is_staff == "true")
user.save() user.save()
if is_superuser is not None:
user.is_superuser = (is_superuser == "true")
user.save()
# Activate the user # Activate the user
reg.activate() reg.activate()
reg.save() reg.save()
......
...@@ -928,6 +928,17 @@ class CourseFields(object): ...@@ -928,6 +928,17 @@ class CourseFields(object):
scope=Scope.settings, 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 class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method
""" """
...@@ -1573,3 +1584,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1573,3 +1584,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
if p.scheme != scheme if p.scheme != scheme
] ]
self.user_partitions = other_partitions + partitions # pylint: disable=attribute-defined-outside-init 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): ...@@ -354,6 +354,17 @@ class TeamsConfigurationTestCase(unittest.TestCase):
self.assertEqual(self.course.teams_topics, topics) 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): class CourseDescriptorTestCase(unittest.TestCase):
""" """
Tests for a select few functions from CourseDescriptor. Tests for a select few functions from CourseDescriptor.
......
"""
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): ...@@ -131,6 +131,47 @@ class SettingsPage(CoursePage):
raise Exception("Invalid license name: {name}".format(name=license_name)) raise Exception("Invalid license name: {name}".format(name=license_name))
button.click() 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 # Waits
################ ################
......
...@@ -14,6 +14,7 @@ from ...pages.studio.utils import add_discussion, drag, verify_ordering ...@@ -14,6 +14,7 @@ from ...pages.studio.utils import add_discussion, drag, verify_ordering
from ...pages.lms.courseware import CoursewarePage from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.course_nav import CourseNavPage from ...pages.lms.course_nav import CourseNavPage
from ...pages.lms.staff_view import StaffPage from ...pages.lms.staff_view import StaffPage
from ...fixtures.config import ConfigModelFixture
from ...fixtures.course import XBlockFixtureDesc from ...fixtures.course import XBlockFixtureDesc
from base_studio_test import StudioCourseTest from base_studio_test import StudioCourseTest
...@@ -1752,3 +1753,57 @@ class DeprecationWarningMessageTest(CourseOutlineTest): ...@@ -1752,3 +1753,57 @@ class DeprecationWarningMessageTest(CourseOutlineTest):
components_present=True, components_present=True,
components_display_name_list=['Open', 'Peer'] 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())
""" """
Acceptance tests for Studio's Settings Details pages Acceptance tests for Studio's Settings Details pages
""" """
from datetime import datetime, timedelta
from unittest import skip from unittest import skip
from .base_studio_test import StudioCourseTest from .base_studio_test import StudioCourseTest
from ...fixtures.config import ConfigModelFixture
from ...fixtures.course import CourseFixture from ...fixtures.course import CourseFixture
from ...pages.studio.settings import SettingsPage from ...pages.studio.settings import SettingsPage
from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.overview import CourseOutlinePage
...@@ -16,12 +18,11 @@ from ..helpers import ( ...@@ -16,12 +18,11 @@ from ..helpers import (
) )
class SettingsMilestonesTest(StudioCourseTest): class StudioSettingsDetailsTest(StudioCourseTest):
""" """Base class for settings and details page tests."""
Tests for milestones feature in Studio's settings tab
"""
def setUp(self, is_staff=True): 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.settings_detail = SettingsPage(
self.browser, self.browser,
self.course_info['org'], self.course_info['org'],
...@@ -33,6 +34,11 @@ class SettingsMilestonesTest(StudioCourseTest): ...@@ -33,6 +34,11 @@ class SettingsMilestonesTest(StudioCourseTest):
self.settings_detail.visit() self.settings_detail.visit()
self.assertTrue(self.settings_detail.is_browser_on_page()) 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): def test_page_has_prerequisite_field(self):
""" """
Test to make sure page has pre-requisite course field if milestones app is enabled. Test to make sure page has pre-requisite course field if milestones app is enabled.
...@@ -193,3 +199,50 @@ class SettingsMilestonesTest(StudioCourseTest): ...@@ -193,3 +199,50 @@ class SettingsMilestonesTest(StudioCourseTest):
css_selector='.add-item a.button-new', css_selector='.add-item a.button-new',
text='New Subsection' 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 ...@@ -9,6 +9,7 @@ from nose.plugins.attrib import attr
from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
from django.test.utils import override_settings 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 request_cache.middleware import RequestCache
from student.tests.factories import AdminFactory # pylint: disable=import-error from student.tests.factories import AdminFactory # pylint: disable=import-error
from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.django_utils import (
...@@ -69,13 +70,7 @@ class TestFieldOverrides(ModuleStoreTestCase): ...@@ -69,13 +70,7 @@ class TestFieldOverrides(ModuleStoreTestCase):
self.addCleanup(RequestCache.clear_request_cache) self.addCleanup(RequestCache.clear_request_cache)
# Apparently the test harness doesn't use LmsFieldStorage, and I'm not inject_field_overrides(iter_blocks(ccx.course), course, AdminFactory.create())
# 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
def cleanup_provider_classes(): def cleanup_provider_classes():
""" """
......
...@@ -24,7 +24,7 @@ from xblock.field_data import FieldData ...@@ -24,7 +24,7 @@ from xblock.field_data import FieldData
from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.inheritance import InheritanceMixin
NOTSET = object() 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): def resolve_dotted(name):
...@@ -77,7 +77,6 @@ class OverrideFieldData(FieldData): ...@@ -77,7 +77,6 @@ class OverrideFieldData(FieldData):
settings.FIELD_OVERRIDE_PROVIDERS)) settings.FIELD_OVERRIDE_PROVIDERS))
enabled_providers = cls._providers_for_course(course) enabled_providers = cls._providers_for_course(course)
if enabled_providers: if enabled_providers:
# TODO: we might not actually want to return here. Might be better # TODO: we might not actually want to return here. Might be better
# to check for instance.providers after the instance is built. This # to check for instance.providers after the instance is built. This
...@@ -98,14 +97,16 @@ class OverrideFieldData(FieldData): ...@@ -98,14 +97,16 @@ class OverrideFieldData(FieldData):
course: The course XBlock course: The course XBlock
""" """
request_cache = RequestCache.get_request_cache() request_cache = RequestCache.get_request_cache()
enabled_providers = request_cache.data.get( if course is None:
ENABLED_OVERRIDE_PROVIDERS_KEY, NOTSET 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: if enabled_providers == NOTSET:
enabled_providers = tuple( enabled_providers = tuple(
(provider_class for provider_class in cls.provider_classes if provider_class.enabled_for(course)) (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 return enabled_providers
......
"""
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 ...@@ -7,12 +7,13 @@ from urllib import urlencode
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from util.date_utils import strftime_localized 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.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 student.models import CourseEnrollment
from .helpers import LoginEnrollmentTestCase from .helpers import LoginEnrollmentTestCase
...@@ -114,3 +115,34 @@ class CourseInfoTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -114,3 +115,34 @@ class CourseInfoTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
resp = self.client.get(url) resp = self.client.get(url)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertNotIn(self.xml_data, resp.content) 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): ...@@ -132,3 +132,16 @@ class TestOverrideProvider(FieldOverrideProvider):
@classmethod @classmethod
def enabled_for(cls, course): def enabled_for(cls, course):
return True 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
"""
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 ...@@ -6,6 +6,7 @@ import cgi
from urllib import urlencode from urllib import urlencode
import ddt import ddt
import json import json
import itertools
import unittest import unittest
from datetime import datetime from datetime import datetime
from HTMLParser import HTMLParser from HTMLParser import HTMLParser
...@@ -36,6 +37,7 @@ from courseware.testutils import RenderXBlockTestMixin ...@@ -36,6 +37,7 @@ from courseware.testutils import RenderXBlockTestMixin
from courseware.tests.factories import StudentModuleFactory from courseware.tests.factories import StudentModuleFactory
from courseware.user_state_client import DjangoXBlockUserStateClient from courseware.user_state_client import DjangoXBlockUserStateClient
from edxmako.tests import mako_middleware_process_request from edxmako.tests import mako_middleware_process_request
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory
from util.tests.test_date_utils import fake_ugettext, fake_pgettext from util.tests.test_date_utils import fake_ugettext, fake_pgettext
...@@ -45,7 +47,7 @@ from xmodule.modulestore import ModuleStoreEnum ...@@ -45,7 +47,7 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore 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 TEST_DATA_MIXED_TOY_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase 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') @attr('shard_1')
...@@ -669,10 +671,18 @@ class ProgressPageTests(ModuleStoreTestCase): ...@@ -669,10 +671,18 @@ class ProgressPageTests(ModuleStoreTestCase):
self.request.user = self.user self.request.user = self.user
mako_middleware_process_request(self.request) mako_middleware_process_request(self.request)
self.setup_course()
def setup_course(self, **options):
"""Create the test course."""
course = CourseFactory.create( course = CourseFactory.create(
start=datetime(2013, 9, 16, 7, 17, 28), start=datetime(2013, 9, 16, 7, 17, 28),
grade_cutoffs={u'çü†øƒƒ': 0.75, 'Pass': 0.5}, grade_cutoffs={u'çü†øƒƒ': 0.75, 'Pass': 0.5},
**options
) )
# pylint: disable=attribute-defined-outside-init
self.course = modulestore().get_course(course.id) self.course = modulestore().get_course(course.id)
CourseEnrollmentFactory(user=self.user, course_id=self.course.id) CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
...@@ -829,6 +839,18 @@ class ProgressPageTests(ModuleStoreTestCase): ...@@ -829,6 +839,18 @@ class ProgressPageTests(ModuleStoreTestCase):
resp = views.progress(self.request, course_id=unicode(self.course.id)) resp = views.progress(self.request, course_id=unicode(self.course.id))
self.assertContains(resp, u"Download Your Certificate") 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') @attr('shard_1')
class VerifyCourseKeyDecoratorTests(TestCase): class VerifyCourseKeyDecoratorTests(TestCase):
...@@ -1151,3 +1173,17 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase): ...@@ -1151,3 +1173,17 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase):
if url_encoded_params: if url_encoded_params:
url += '?' + url_encoded_params url += '?' + url_encoded_params
return self.client.get(url) 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): ...@@ -55,6 +55,13 @@ class RenderXBlockTestMixin(object):
""" """
self.client.login(username=self.user.username, password='test') 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): def setup_course(self, default_store=None):
""" """
Helper method to create the course. Helper method to create the course.
...@@ -62,7 +69,7 @@ class RenderXBlockTestMixin(object): ...@@ -62,7 +69,7 @@ class RenderXBlockTestMixin(object):
if not default_store: if not default_store:
default_store = self.store.default_modulestore.get_modulestore_type() default_store = self.store.default_modulestore.get_modulestore_type()
with self.store.default_store(default_store): 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') chapter = ItemFactory.create(parent=self.course, category='chapter')
self.html_block = ItemFactory.create( # pylint: disable=attribute-defined-outside-init self.html_block = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
parent=chapter, parent=chapter,
......
...@@ -12,6 +12,7 @@ from django.test.utils import override_settings ...@@ -12,6 +12,7 @@ from django.test.utils import override_settings
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error 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 student.tests.factories import UserFactory # pylint: disable=import-error
from xmodule.fields import Date from xmodule.fields import Date
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
...@@ -196,7 +197,6 @@ class TestSetDueDateExtension(ModuleStoreTestCase): ...@@ -196,7 +197,6 @@ class TestSetDueDateExtension(ModuleStoreTestCase):
Fixtures. Fixtures.
""" """
super(TestSetDueDateExtension, self).setUp() super(TestSetDueDateExtension, self).setUp()
OverrideFieldData.provider_classes = None
self.due = due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc) self.due = due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc)
course = CourseFactory.create() course = CourseFactory.create()
...@@ -216,12 +216,7 @@ class TestSetDueDateExtension(ModuleStoreTestCase): ...@@ -216,12 +216,7 @@ class TestSetDueDateExtension(ModuleStoreTestCase):
self.week3 = week3 self.week3 = week3
self.user = user self.user = user
# Apparently the test harness doesn't use LmsFieldStorage, and I'm not inject_field_overrides((course, week1, week2, week3, homework, assignment), course, user)
# 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
def tearDown(self): def tearDown(self):
super(TestSetDueDateExtension, self).tearDown() super(TestSetDueDateExtension, self).tearDown()
......
...@@ -676,6 +676,11 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'): ...@@ -676,6 +676,11 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'):
'courseware.student_field_overrides.IndividualStudentOverrideProvider', 'courseware.student_field_overrides.IndividualStudentOverrideProvider',
) )
##### Self-Paced Course Due Dates #####
FIELD_OVERRIDE_PROVIDERS += (
'courseware.self_paced_overrides.SelfPacedDateOverrideProvider',
)
# PROFILE IMAGE CONFIG # PROFILE IMAGE CONFIG
PROFILE_IMAGE_BACKEND = ENV_TOKENS.get('PROFILE_IMAGE_BACKEND', PROFILE_IMAGE_BACKEND) 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) PROFILE_IMAGE_SECRET_KEY = AUTH_TOKENS.get('PROFILE_IMAGE_SECRET_KEY', PROFILE_IMAGE_SECRET_KEY)
......
...@@ -128,6 +128,9 @@ FEATURES['ENABLE_TEAMS'] = True ...@@ -128,6 +128,9 @@ FEATURES['ENABLE_TEAMS'] = True
# Enable custom content licensing # Enable custom content licensing
FEATURES['LICENSING'] = True FEATURES['LICENSING'] = True
# Use the auto_auth workflow for creating users and logging them in
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
########################### Entrance Exams ################################# ########################### Entrance Exams #################################
FEATURES['MILESTONES_APP'] = True FEATURES['MILESTONES_APP'] = True
FEATURES['ENTRANCE_EXAMS'] = True FEATURES['ENTRANCE_EXAMS'] = True
......
...@@ -1966,6 +1966,9 @@ INSTALLED_APPS = ( ...@@ -1966,6 +1966,9 @@ INSTALLED_APPS = (
# programs support # programs support
'openedx.core.djangoapps.programs', 'openedx.core.djangoapps.programs',
# Self-paced course configuration
'openedx.core.djangoapps.self_paced',
) )
######################### CSRF ######################################### ######################### CSRF #########################################
......
...@@ -11,6 +11,9 @@ import django.contrib.auth.views ...@@ -11,6 +11,9 @@ import django.contrib.auth.views
from microsite_configuration import microsite from microsite_configuration import microsite
import auth_exchange.views 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: # Uncomment the next two lines to enable the admin:
if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
admin.autodiscover() admin.autodiscover()
...@@ -739,6 +742,10 @@ if settings.FEATURES.get("ENABLE_LTI_PROVIDER"): ...@@ -739,6 +742,10 @@ if settings.FEATURES.get("ENABLE_LTI_PROVIDER"):
url(r'^lti_provider/', include('lti_provider.urls')), url(r'^lti_provider/', include('lti_provider.urls')),
) )
urlpatterns += (
url(r'config/self_paced', ConfigurationModelCurrentAPIView.as_view(model=SelfPacedConfiguration)),
)
urlpatterns = patterns(*urlpatterns) urlpatterns = patterns(*urlpatterns)
if settings.DEBUG: if settings.DEBUG:
......
"""
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)
# -*- 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
"""
Configuration for self-paced courses.
"""
from config_models.models import ConfigurationModel
class SelfPacedConfiguration(ConfigurationModel):
"""
Configuration for self-paced courses.
"""
pass
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment