Merge pull request #10404 from edx/feature/self-paced
Enable self-paced courses.
This commit is contained in:
@@ -93,6 +93,8 @@ class ConfigurationModel(models.Model):
|
||||
"""
|
||||
Clear the cached value when saving a new configuration entry
|
||||
"""
|
||||
# Always create a new entry, instead of updating an existing model
|
||||
self.pk = None # pylint: disable=invalid-name
|
||||
super(ConfigurationModel, self).save(*args, **kwargs)
|
||||
cache.delete(self.cache_key_name(*[getattr(self, key) for key in self.KEY_FIELDS]))
|
||||
if self.KEY_FIELDS:
|
||||
|
||||
@@ -7,10 +7,13 @@ import ddt
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mock import patch
|
||||
from mock import patch, Mock
|
||||
from config_models.models import ConfigurationModel
|
||||
from config_models.views import ConfigurationModelCurrentAPIView
|
||||
|
||||
|
||||
class ExampleConfig(ConfigurationModel):
|
||||
@@ -92,6 +95,14 @@ class ConfigurationModelTests(TestCase):
|
||||
self.assertEqual(rows[1].string_field, 'first')
|
||||
self.assertEqual(rows[1].is_active, False)
|
||||
|
||||
def test_always_insert(self, __):
|
||||
config = ExampleConfig(changed_by=self.user, string_field='first')
|
||||
config.save()
|
||||
config.string_field = 'second'
|
||||
config.save()
|
||||
|
||||
self.assertEquals(2, ExampleConfig.objects.all().count())
|
||||
|
||||
|
||||
class ExampleKeyedConfig(ConfigurationModel):
|
||||
"""
|
||||
@@ -282,3 +293,86 @@ class KeyedConfigurationModelTests(TestCase):
|
||||
fake_result = [('a', 'b'), ('c', 'd')]
|
||||
mock_cache.get.return_value = fake_result
|
||||
self.assertEquals(ExampleKeyedConfig.key_values(), fake_result)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ConfigurationModelAPITests(TestCase):
|
||||
"""
|
||||
Tests for the configuration model API.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(ConfigurationModelAPITests, self).setUp()
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = User.objects.create_user(
|
||||
username='test_user',
|
||||
email='test_user@example.com',
|
||||
password='test_pass',
|
||||
)
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
|
||||
self.current_view = ConfigurationModelCurrentAPIView.as_view(model=ExampleConfig)
|
||||
|
||||
# Disable caching while testing the API
|
||||
patcher = patch('config_models.models.cache', Mock(get=Mock(return_value=None)))
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_insert(self):
|
||||
self.assertEquals("", ExampleConfig.current().string_field)
|
||||
|
||||
request = self.factory.post('/config/ExampleConfig', {"string_field": "string_value"})
|
||||
request.user = self.user
|
||||
__ = self.current_view(request)
|
||||
|
||||
self.assertEquals("string_value", ExampleConfig.current().string_field)
|
||||
self.assertEquals(self.user, ExampleConfig.current().changed_by)
|
||||
|
||||
def test_multiple_inserts(self):
|
||||
for i in xrange(3):
|
||||
self.assertEquals(i, ExampleConfig.objects.all().count())
|
||||
|
||||
request = self.factory.post('/config/ExampleConfig', {"string_field": str(i)})
|
||||
request.user = self.user
|
||||
response = self.current_view(request)
|
||||
self.assertEquals(201, response.status_code)
|
||||
|
||||
self.assertEquals(i + 1, ExampleConfig.objects.all().count())
|
||||
self.assertEquals(str(i), ExampleConfig.current().string_field)
|
||||
|
||||
def test_get_current(self):
|
||||
request = self.factory.get('/config/ExampleConfig')
|
||||
request.user = self.user
|
||||
response = self.current_view(request)
|
||||
# pylint: disable=no-member
|
||||
self.assertEquals('', response.data['string_field'])
|
||||
self.assertEquals(10, response.data['int_field'])
|
||||
self.assertEquals(None, response.data['changed_by'])
|
||||
self.assertEquals(False, response.data['enabled'])
|
||||
self.assertEquals(None, response.data['change_date'])
|
||||
|
||||
ExampleConfig(string_field='string_value', int_field=20).save()
|
||||
|
||||
response = self.current_view(request)
|
||||
self.assertEquals('string_value', response.data['string_field'])
|
||||
self.assertEquals(20, response.data['int_field'])
|
||||
|
||||
@ddt.data(
|
||||
('get', [], 200),
|
||||
('post', [{'string_field': 'string_value', 'int_field': 10}], 201),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_permissions(self, method, args, status_code):
|
||||
request = getattr(self.factory, method)('/config/ExampleConfig', *args)
|
||||
|
||||
request.user = User.objects.create_user(
|
||||
username='no-perms',
|
||||
email='no-perms@example.com',
|
||||
password='no-perms',
|
||||
)
|
||||
response = self.current_view(request)
|
||||
self.assertEquals(403, response.status_code)
|
||||
|
||||
request.user = self.user
|
||||
response = self.current_view(request)
|
||||
self.assertEquals(status_code, response.status_code)
|
||||
|
||||
50
common/djangoapps/config_models/views.py
Normal file
50
common/djangoapps/config_models/views.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
API view to allow manipulation of configuration models.
|
||||
"""
|
||||
from rest_framework.generics import CreateAPIView, RetrieveAPIView
|
||||
from rest_framework.permissions import DjangoModelPermissions
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
|
||||
|
||||
class ReadableOnlyByAuthors(DjangoModelPermissions):
|
||||
"""Only allow access by users with `add` permissions on the model."""
|
||||
perms_map = DjangoModelPermissions.perms_map.copy()
|
||||
perms_map['GET'] = perms_map['OPTIONS'] = perms_map['HEAD'] = perms_map['POST']
|
||||
|
||||
|
||||
class ConfigurationModelCurrentAPIView(CreateAPIView, RetrieveAPIView):
|
||||
"""
|
||||
This view allows an authenticated user with the appropriate model permissions
|
||||
to read and write the current configuration for the specified `model`.
|
||||
|
||||
Like other APIViews, you can use this by using a url pattern similar to the following::
|
||||
|
||||
url(r'config/example_config$', ConfigurationModelCurrentAPIView.as_view(model=ExampleConfig))
|
||||
"""
|
||||
authentication_classes = (SessionAuthentication,)
|
||||
permission_classes = (ReadableOnlyByAuthors,)
|
||||
model = None
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.all()
|
||||
|
||||
def get_object(self):
|
||||
# Return the currently active configuration
|
||||
return self.model.current()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.serializer_class is None:
|
||||
class AutoConfigModelSerializer(ModelSerializer):
|
||||
"""Serializer class for configuration models."""
|
||||
class Meta(object):
|
||||
"""Meta information for AutoConfigModelSerializer."""
|
||||
model = self.model
|
||||
|
||||
self.serializer_class = AutoConfigModelSerializer
|
||||
|
||||
return self.serializer_class
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# Set the requesting user as the one who is updating the configuration
|
||||
serializer.save(changed_by=self.request.user)
|
||||
@@ -1796,6 +1796,7 @@ def auto_auth(request):
|
||||
email = request.GET.get('email', unique_name + "@example.com")
|
||||
full_name = request.GET.get('full_name', username)
|
||||
is_staff = request.GET.get('staff', None)
|
||||
is_superuser = request.GET.get('superuser', None)
|
||||
course_id = request.GET.get('course_id', None)
|
||||
|
||||
# mode has to be one of 'honor'/'professional'/'verified'/'audit'/'no-id-professional'/'credit'
|
||||
@@ -1836,6 +1837,10 @@ def auto_auth(request):
|
||||
user.is_staff = (is_staff == "true")
|
||||
user.save()
|
||||
|
||||
if is_superuser is not None:
|
||||
user.is_superuser = (is_superuser == "true")
|
||||
user.save()
|
||||
|
||||
# Activate the user
|
||||
reg.activate()
|
||||
reg.save()
|
||||
|
||||
@@ -928,6 +928,17 @@ class CourseFields(object):
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
self_paced = Boolean(
|
||||
display_name=_("Self Paced"),
|
||||
help=_(
|
||||
"Set this to \"true\" to mark this course as self-paced. Self-paced courses do not have "
|
||||
"due dates for assignments, and students can progress through the course at any rate before "
|
||||
"the course ends."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
|
||||
class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method
|
||||
"""
|
||||
@@ -1573,3 +1584,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
|
||||
if p.scheme != scheme
|
||||
]
|
||||
self.user_partitions = other_partitions + partitions # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
@property
|
||||
def can_toggle_course_pacing(self):
|
||||
"""
|
||||
Whether or not the course can be set to self-paced at this time.
|
||||
|
||||
Returns:
|
||||
bool: False if the course has already started, True otherwise.
|
||||
"""
|
||||
return datetime.now(UTC()) <= self.start
|
||||
|
||||
@@ -354,6 +354,17 @@ class TeamsConfigurationTestCase(unittest.TestCase):
|
||||
self.assertEqual(self.course.teams_topics, topics)
|
||||
|
||||
|
||||
class SelfPacedTestCase(unittest.TestCase):
|
||||
"""Tests for self-paced courses."""
|
||||
|
||||
def setUp(self):
|
||||
super(SelfPacedTestCase, self).setUp()
|
||||
self.course = get_dummy_course('2012-12-02T12:00')
|
||||
|
||||
def test_default(self):
|
||||
self.assertFalse(self.course.self_paced)
|
||||
|
||||
|
||||
class CourseDescriptorTestCase(unittest.TestCase):
|
||||
"""
|
||||
Tests for a select few functions from CourseDescriptor.
|
||||
|
||||
95
common/test/acceptance/fixtures/config.py
Normal file
95
common/test/acceptance/fixtures/config.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Fixture to manipulate configuration models.
|
||||
"""
|
||||
import requests
|
||||
import re
|
||||
import json
|
||||
|
||||
from lazy import lazy
|
||||
from . import LMS_BASE_URL
|
||||
|
||||
|
||||
class ConfigModelFixureError(Exception):
|
||||
"""
|
||||
Error occurred while configuring the stub XQueue.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigModelFixture(object):
|
||||
"""
|
||||
Configure a ConfigurationModel by using it's JSON api.
|
||||
"""
|
||||
|
||||
def __init__(self, api_base, configuration):
|
||||
"""
|
||||
Configure a ConfigurationModel exposed at `api_base` to have the configuration `configuration`.
|
||||
"""
|
||||
self._api_base = api_base
|
||||
self._configuration = configuration
|
||||
|
||||
def install(self):
|
||||
"""
|
||||
Configure the stub via HTTP.
|
||||
"""
|
||||
url = LMS_BASE_URL + self._api_base
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
data=json.dumps(self._configuration),
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise ConfigModelFixureError(
|
||||
"Could not configure url '{}'. response: {} - {}".format(
|
||||
self._api_base,
|
||||
response,
|
||||
response.content,
|
||||
)
|
||||
)
|
||||
|
||||
@lazy
|
||||
def session_cookies(self):
|
||||
"""
|
||||
Log in as a staff user, then return the cookies for the session (as a dict)
|
||||
Raises a `ConfigModelFixureError` if the login fails.
|
||||
"""
|
||||
return {key: val for key, val in self.session.cookies.items()}
|
||||
|
||||
@lazy
|
||||
def headers(self):
|
||||
"""
|
||||
Default HTTP headers dict.
|
||||
"""
|
||||
return {
|
||||
'Content-type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRFToken': self.session_cookies.get('csrftoken', '')
|
||||
}
|
||||
|
||||
@lazy
|
||||
def session(self):
|
||||
"""
|
||||
Log in as a staff user, then return a `requests` `session` object for the logged in user.
|
||||
Raises a `StudioApiLoginError` if the login fails.
|
||||
"""
|
||||
# Use auto-auth to retrieve the session for a logged in user
|
||||
session = requests.Session()
|
||||
response = session.get(LMS_BASE_URL + "/auto_auth?superuser=true")
|
||||
|
||||
# Return the session from the request
|
||||
if response.ok:
|
||||
# auto_auth returns information about the newly created user
|
||||
# capture this so it can be used by by the testcases.
|
||||
user_pattern = re.compile(r'Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
|
||||
r'(?P<username>\S+)', r'(?P<email>[^\)]+)', r'(?P<password>\S+)', r'(?P<user_id>\d+)'))
|
||||
user_matches = re.match(user_pattern, response.text)
|
||||
if user_matches:
|
||||
self.user = user_matches.groupdict() # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
return session
|
||||
|
||||
else:
|
||||
msg = "Could not log in to use ConfigModel restful API. Status code: {0}".format(response.status_code)
|
||||
raise ConfigModelFixureError(msg)
|
||||
@@ -131,6 +131,47 @@ class SettingsPage(CoursePage):
|
||||
raise Exception("Invalid license name: {name}".format(name=license_name))
|
||||
button.click()
|
||||
|
||||
pacing_css = 'section.pacing input[type=radio]'
|
||||
|
||||
@property
|
||||
def checked_pacing_css(self):
|
||||
"""CSS for the course pacing button which is currently checked."""
|
||||
return self.pacing_css + ':checked'
|
||||
|
||||
@property
|
||||
def course_pacing(self):
|
||||
"""
|
||||
Returns the label text corresponding to the checked pacing radio button.
|
||||
"""
|
||||
self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present and rendered')
|
||||
checked = self.q(css=self.checked_pacing_css).results[0]
|
||||
checked_id = checked.get_attribute('id')
|
||||
return self.q(css='label[for={checked_id}]'.format(checked_id=checked_id)).results[0].text
|
||||
|
||||
@course_pacing.setter
|
||||
def course_pacing(self, pacing):
|
||||
"""
|
||||
Sets the course to either self-paced or instructor-led by checking
|
||||
the appropriate radio button.
|
||||
"""
|
||||
self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present')
|
||||
self.q(xpath="//label[contains(text(), '{pacing}')]".format(pacing=pacing)).click()
|
||||
|
||||
@property
|
||||
def course_pacing_disabled_text(self):
|
||||
"""
|
||||
Return the message indicating that course pacing cannot be toggled.
|
||||
"""
|
||||
return self.q(css='#course-pace-toggle-tip').results[0].text
|
||||
|
||||
def course_pacing_disabled(self):
|
||||
"""
|
||||
Return True if the course pacing controls are disabled; False otherwise.
|
||||
"""
|
||||
self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present')
|
||||
statuses = self.q(css=self.pacing_css).map(lambda e: e.get_attribute('disabled')).results
|
||||
return all((s == 'true' for s in statuses))
|
||||
|
||||
################
|
||||
# Waits
|
||||
################
|
||||
|
||||
@@ -14,6 +14,7 @@ from ...pages.studio.utils import add_discussion, drag, verify_ordering
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.course_nav import CourseNavPage
|
||||
from ...pages.lms.staff_view import StaffPage
|
||||
from ...fixtures.config import ConfigModelFixture
|
||||
from ...fixtures.course import XBlockFixtureDesc
|
||||
|
||||
from base_studio_test import StudioCourseTest
|
||||
@@ -1752,3 +1753,57 @@ class DeprecationWarningMessageTest(CourseOutlineTest):
|
||||
components_present=True,
|
||||
components_display_name_list=['Open', 'Peer']
|
||||
)
|
||||
|
||||
|
||||
class SelfPacedOutlineTest(CourseOutlineTest):
|
||||
"""Test the course outline for a self-paced course."""
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', SECTION_NAME).add_children(
|
||||
XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children(
|
||||
XBlockFixtureDesc('vertical', UNIT_NAME)
|
||||
)
|
||||
),
|
||||
)
|
||||
self.course_fixture.add_course_details({
|
||||
'self_paced': True,
|
||||
'start_date': datetime.now() + timedelta(days=1)
|
||||
})
|
||||
ConfigModelFixture('/config/self_paced', {'enabled': True}).install()
|
||||
|
||||
def test_release_dates_not_shown(self):
|
||||
"""
|
||||
Scenario: Ensure that block release dates are not shown on the
|
||||
course outline page of a self-paced course.
|
||||
|
||||
Given I am the author of a self-paced course
|
||||
When I go to the course outline
|
||||
Then I should not see release dates for course content
|
||||
"""
|
||||
self.course_outline_page.visit()
|
||||
section = self.course_outline_page.section(SECTION_NAME)
|
||||
self.assertEqual(section.release_date, '')
|
||||
subsection = section.subsection(SUBSECTION_NAME)
|
||||
self.assertEqual(subsection.release_date, '')
|
||||
|
||||
def test_edit_section_and_subsection(self):
|
||||
"""
|
||||
Scenario: Ensure that block release/due dates are not shown
|
||||
in their settings modals.
|
||||
|
||||
Given I am the author of a self-paced course
|
||||
When I go to the course outline
|
||||
And I click on settings for a section or subsection
|
||||
Then I should not see release or due date settings
|
||||
"""
|
||||
self.course_outline_page.visit()
|
||||
section = self.course_outline_page.section(SECTION_NAME)
|
||||
modal = section.edit()
|
||||
self.assertFalse(modal.has_release_date())
|
||||
self.assertFalse(modal.has_due_date())
|
||||
modal.cancel()
|
||||
subsection = section.subsection(SUBSECTION_NAME)
|
||||
modal = subsection.edit()
|
||||
self.assertFalse(modal.has_release_date())
|
||||
self.assertFalse(modal.has_due_date())
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
Acceptance tests for Studio's Settings Details pages
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from unittest import skip
|
||||
|
||||
from .base_studio_test import StudioCourseTest
|
||||
from ...fixtures.config import ConfigModelFixture
|
||||
from ...fixtures.course import CourseFixture
|
||||
from ...pages.studio.settings import SettingsPage
|
||||
from ...pages.studio.overview import CourseOutlinePage
|
||||
@@ -16,12 +18,11 @@ from ..helpers import (
|
||||
)
|
||||
|
||||
|
||||
class SettingsMilestonesTest(StudioCourseTest):
|
||||
"""
|
||||
Tests for milestones feature in Studio's settings tab
|
||||
"""
|
||||
class StudioSettingsDetailsTest(StudioCourseTest):
|
||||
"""Base class for settings and details page tests."""
|
||||
|
||||
def setUp(self, is_staff=True):
|
||||
super(SettingsMilestonesTest, self).setUp(is_staff=is_staff)
|
||||
super(StudioSettingsDetailsTest, self).setUp(is_staff=is_staff)
|
||||
self.settings_detail = SettingsPage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
@@ -33,6 +34,11 @@ class SettingsMilestonesTest(StudioCourseTest):
|
||||
self.settings_detail.visit()
|
||||
self.assertTrue(self.settings_detail.is_browser_on_page())
|
||||
|
||||
|
||||
class SettingsMilestonesTest(StudioSettingsDetailsTest):
|
||||
"""
|
||||
Tests for milestones feature in Studio's settings tab
|
||||
"""
|
||||
def test_page_has_prerequisite_field(self):
|
||||
"""
|
||||
Test to make sure page has pre-requisite course field if milestones app is enabled.
|
||||
@@ -193,3 +199,50 @@ class SettingsMilestonesTest(StudioCourseTest):
|
||||
css_selector='.add-item a.button-new',
|
||||
text='New Subsection'
|
||||
))
|
||||
|
||||
|
||||
class CoursePacingTest(StudioSettingsDetailsTest):
|
||||
"""Tests for setting a course to self-paced."""
|
||||
|
||||
def populate_course_fixture(self, __):
|
||||
ConfigModelFixture('/config/self_paced', {'enabled': True}).install()
|
||||
# Set the course start date to tomorrow in order to allow setting pacing
|
||||
self.course_fixture.add_course_details({'start_date': datetime.now() + timedelta(days=1)})
|
||||
|
||||
def test_default_instructor_led(self):
|
||||
"""
|
||||
Test that the 'instructor led' button is checked by default.
|
||||
"""
|
||||
self.assertEqual(self.settings_detail.course_pacing, 'Instructor-Led')
|
||||
|
||||
def test_self_paced(self):
|
||||
"""
|
||||
Test that the 'self-paced' button is checked for a self-paced
|
||||
course.
|
||||
"""
|
||||
self.course_fixture.add_course_details({
|
||||
'self_paced': True
|
||||
})
|
||||
self.course_fixture.configure_course()
|
||||
self.settings_detail.refresh_page()
|
||||
self.assertEqual(self.settings_detail.course_pacing, 'Self-Paced')
|
||||
|
||||
def test_set_self_paced(self):
|
||||
"""
|
||||
Test that the self-paced option is persisted correctly.
|
||||
"""
|
||||
self.settings_detail.course_pacing = 'Self-Paced'
|
||||
self.settings_detail.save_changes()
|
||||
self.settings_detail.refresh_page()
|
||||
self.assertEqual(self.settings_detail.course_pacing, 'Self-Paced')
|
||||
|
||||
def test_toggle_pacing_after_course_start(self):
|
||||
"""
|
||||
Test that course authors cannot toggle the pacing of their course
|
||||
while the course is running.
|
||||
"""
|
||||
self.course_fixture.add_course_details({'start_date': datetime.now()})
|
||||
self.course_fixture.configure_course()
|
||||
self.settings_detail.refresh_page()
|
||||
self.assertTrue(self.settings_detail.course_pacing_disabled())
|
||||
self.assertIn('Course pacing cannot be changed', self.settings_detail.course_pacing_disabled_text)
|
||||
|
||||
Reference in New Issue
Block a user