Skip to content
Snippets Groups Projects
Commit d9751a85 authored by Peter Fogg's avatar Peter Fogg
Browse files

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

Enable self-paced courses.
parents e51dbc4d 505b2aa4
No related merge requests found
Showing
with 327 additions and 35 deletions
......@@ -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;
......
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)
"""
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()
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment