Merge pull request #28016 from edx/syoon/AA-844

feat: AA-883 Basic prototype for self paced due dates in Studio
This commit is contained in:
sofiayoon
2021-07-09 09:23:05 -04:00
committed by GitHub
13 changed files with 540 additions and 23 deletions

View File

@@ -67,3 +67,15 @@ REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND = LegacyWaffleFlag(
flag_name='library_authoring_mfe',
module_name=__name__,
)
# .. toggle_name: studio.custom_pls
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable custom pacing for PLS
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-07-08
# .. toggle_target_removal_date: 2021-12-31
# .. toggle_warnings: For this flag to be active, add flag 'studio.custom_pls' in Django Admin
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844
CUSTOM_PLS = CourseWaffleFlag(WAFFLE_NAMESPACE, 'custom_pls', module_name=__name__,)

View File

@@ -9,6 +9,4 @@ def should_show_checklists_quality(course_key):
Determine if the ENABLE_CHECKLISTS_QUALITY waffle flag is set
and if the user is able to see it
"""
if ENABLE_CHECKLISTS_QUALITY.is_enabled(course_key):
return True
return False
return ENABLE_CHECKLISTS_QUALITY.is_enabled(course_key)

View File

@@ -1230,6 +1230,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
'graded': xblock.graded,
'due_date': get_default_time_display(xblock.due),
'due': xblock.fields['due'].to_json(xblock.due),
'due_num_weeks': xblock.due_num_weeks,
'format': xblock.format,
'course_graders': [grader.get('type') for grader in graders],
'has_changes': has_changes,

View File

@@ -12,7 +12,7 @@ describe('CourseOutlinePage', function() {
var createCourseOutlinePage, displayNameInput, model, outlinePage, requests, getItemsOfType, getItemHeaders,
verifyItemsExpanded, expandItemsAndVerifyState, collapseItemsAndVerifyState, selectBasicSettings,
selectVisibilitySettings, selectAdvancedSettings, createMockCourseJSON, createMockSectionJSON,
createMockSubsectionJSON, verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, setSelfPaced,
createMockSubsectionJSON, verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, setSelfPaced,setSelfPacedCustomPLS,
mockSingleSectionCourseJSON, createMockVerticalJSON, createMockIndexJSON, mockCourseEntranceExamJSON,
selectOnboardingExam, createMockCourseJSONWithReviewRules,mockCourseJSONWithReviewRules,
mockOutlinePage = readFixtures('templates/mock/mock-course-outline-page.underscore'),
@@ -202,6 +202,11 @@ describe('CourseOutlinePage', function() {
course.set('self_paced', true);
};
setSelfPacedCustomPLS = function() {
setSelfPaced();
course.set('is_custom_pls_active', true);
}
createCourseOutlinePage = function(test, courseJSON, createOnly) {
requests = AjaxHelpers.requests(test);
model = new XBlockOutlineInfo(courseJSON, {parse: true});
@@ -294,7 +299,7 @@ describe('CourseOutlinePage', function() {
TemplateHelpers.installTemplates([
'course-outline', 'xblock-string-field-editor', 'modal-button',
'basic-modal', 'course-outline-modal', 'release-date-editor',
'due-date-editor', 'grading-editor', 'publish-editor',
'due-date-editor', 'self-paced-due-date-editor', 'grading-editor', 'publish-editor',
'staff-lock-editor', 'unit-access-editor', 'content-visibility-editor',
'settings-modal-tabs', 'timed-examination-preference-editor', 'access-editor',
'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor',
@@ -1002,9 +1007,9 @@ describe('CourseOutlinePage', function() {
});
describe('Subsection', function() {
var getDisplayNameWrapper, setEditModalValues, setContentVisibility, mockServerValuesJson,
selectDisableSpecialExams, selectTimedExam, selectProctoredExam, selectPracticeExam,
selectPrerequisite, selectLastPrerequisiteSubsection, checkOptionFieldVisibility,
var getDisplayNameWrapper, setEditModalValues, setEditModalValuesForCustomPacing, setContentVisibility, mockServerValuesJson,
mockCustomPacingServerValuesJson, selectDisableSpecialExams, selectTimedExam, selectProctoredExam, selectPracticeExam,
selectPrerequisite, selectLastPrerequisiteSubsection, selectDueNumWeeksSubsection, checkOptionFieldVisibility,
defaultModalSettings, modalSettingsWithExamReviewRules, getMockNoPrereqOrExamsCourseJSON, expectShowCorrectness;
getDisplayNameWrapper = function() {
@@ -2117,6 +2122,170 @@ describe('CourseOutlinePage', function() {
);
expect($modalWindow.find('.outline-subsection')).not.toExist();
});
describe('Self Paced with Custom Personalized Learner Schedules (PLS)', function () {
beforeEach(function() {
var mockCourseJSON = createMockCourseJSON({}, [
createMockSectionJSON({}, [
createMockSubsectionJSON({}, [])
])
]);
createCourseOutlinePage(this, mockCourseJSON, false);
setSelfPacedCustomPLS();
});
setEditModalValuesForCustomPacing = function(due_in, grading_type) {
$('#due_in').val(due_in);
$('#grading_type').val(grading_type);
};
selectDueNumWeeksSubsection = function(weeks) {
$('#due_in').val(weeks).trigger('keyup');
}
mockCustomPacingServerValuesJson = createMockSectionJSON({
release_date: 'Jan 01, 2970 at 05:00 UTC'
}, [
createMockSubsectionJSON({
graded: true,
due_num_weeks: 3,
format: 'Lab',
has_explicit_staff_lock: true,
staff_only_message: true,
is_prereq: false,
show_correctness: 'never',
is_time_limited: false,
is_practice_exam: false,
is_proctored_exam: false,
default_time_limit_minutes: null,
}, [
createMockVerticalJSON({
has_changes: true,
published: false
})
])
]);
it('can show correct editors for self_paced course with custom pacing', function (){
outlinePage.$('.outline-subsection .configure-button').click();
expect($('.edit-settings-release').length).toBe(0);
// Due date input exists for custom pacing self paced courses
expect($('.grading-due-date').length).toBe(1);
expect($('.edit-settings-grading').length).toBe(1);
expect($('.edit-content-visibility').length).toBe(1);
expect($('.edit-show-correctness').length).toBe(1);
});
it('can be edited when custom pacing for self paced course is active', function() {
outlinePage.$('.outline-subsection .configure-button').click();
setEditModalValuesForCustomPacing('3', 'Lab');
selectAdvancedSettings();
$('.wrapper-modal-window .action-save').click();
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', {
graderType: 'Lab',
isPrereq: false,
metadata: {
due_num_weeks: 3,
is_time_limited: false,
is_practice_exam: false,
is_proctored_enabled: false,
default_time_limit_minutes: null,
is_onboarding_exam: false,
}
});
expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH');
AjaxHelpers.respondWithJson(requests, {});
AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section');
AjaxHelpers.respondWithJson(requests, mockCustomPacingServerValuesJson);
AjaxHelpers.expectNoRequests(requests);
expect($('.outline-subsection .status-grading-value')).toContainText(
'Lab'
);
expect($('.outline-subsection .status-message-copy')).toContainText(
'Contains staff only content'
);
expect($('.outline-item .outline-subsection .status-grading-value')).toContainText('Lab');
outlinePage.$('.outline-item .outline-subsection .configure-button').click();
expect($('#due_in').val()).toBe('3');
expect($('#grading_type').val()).toBe('Lab');
expect($('input[name=content-visibility][value=staff_only]').is(':checked')).toBe(true);
expect($('input.timed_exam').is(':checked')).toBe(false);
expect($('input.proctored_exam').is(':checked')).toBe(false);
expect($('input.no_special_exam').is(':checked')).toBe(true);
expect($('input.practice_exam').is(':checked')).toBe(false);
expectShowCorrectness('never');
});
it('shows validation error on due number of weeks', function() {
outlinePage.$('.outline-subsection .configure-button').click();
// when due number of weeks goes over 18
selectDueNumWeeksSubsection('19');
expect($('#due-num-weeks-warning-max').css('display')).not.toBe('none');
expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
// when due number of weeks is less than 1
selectDueNumWeeksSubsection('-1');
expect($('#due-num-weeks-warning-min').css('display')).not.toBe('none');
expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
// when no validation error should show up
selectDueNumWeeksSubsection('10');
expect($('#due-num-weeks-warning-max').css('display')).toBe('none');
expect($('#due-num-weeks-warning-min').css('display')).toBe('none');
expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(false);
expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(false);
});
it('due num weeks (due_in) can be cleared.', function() {
outlinePage.$('.outline-item .outline-subsection .configure-button').click();
setEditModalValuesForCustomPacing('3', 'Lab');
setContentVisibility('staff_only');
$('.wrapper-modal-window .action-save').click();
// This is the response for the change operation.
AjaxHelpers.respondWithJson(requests, {});
// This is the response for the subsequent fetch operation.
AjaxHelpers.respondWithJson(requests, mockCustomPacingServerValuesJson);
expect($('.outline-subsection .status-grading-value')).toContainText(
'Lab'
);
expect($('.outline-subsection .status-message-copy')).toContainText(
'Contains staff only content'
);
outlinePage.$('.outline-subsection .configure-button').click();
expect($('#due_in').val()).toBe('3');
expect($('#grading_type').val()).toBe('Lab');
expect($('input[name=content-visibility][value=staff_only]').is(':checked')).toBe(true);
$('.wrapper-modal-window .due-date-input .action-clear').click();
expect($('#due_in').val()).toBe('');
$('#grading_type').val('notgraded');
setContentVisibility('visible');
$('.wrapper-modal-window .action-save').click();
// This is the response for the change operation.
AjaxHelpers.respondWithJson(requests, {});
// This is the response for the subsequent fetch operation.
AjaxHelpers.respondWithJson(requests,
createMockSectionJSON({}, [createMockSubsectionJSON()])
);
expect($('.outline-subsection .status-grading-value')).not.toExist();
expect($('.outline-subsection .status-message-copy')).not.toContainText(
'Contains staff only content'
);
});
})
});
// Note: most tests for units can be found in Bok Choy

View File

@@ -15,7 +15,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
'use strict';
var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, HighlightsXBlockModal,
AbstractEditor, BaseDateEditor,
ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor,
ReleaseDateEditor, DueDateEditor, SelfPacedDueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor,
StaffLockEditor, UnitAccessEditor, ContentVisibilityEditor, TimedExaminationPreferenceEditor,
AccessEditor, ShowCorrectnessEditor, HighlightsEditor, HighlightsEnableXBlockModal, HighlightsEnableEditor;
@@ -389,6 +389,58 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
}
});
SelfPacedDueDateEditor = AbstractEditor.extend({
fieldName: 'due_num_weeks',
templateName: 'self-paced-due-date-editor',
className: 'modal-section-content has-actions due-date-input grading-due-date',
events: {
'click .clear-date': 'clearValue',
'keyup #due_in': 'validateDueIn',
'blur #due_in': 'validateDueIn',
},
getValue: function() {
return parseInt(this.$('#due_in').val());
},
validateDueIn: function() {
if (this.getValue() > 18){
this.$('#due-num-weeks-warning-max').show();
BaseModal.prototype.disableActionButton.call(this.parent, 'save');
}
else if (this.getValue() < 1){
this.$('#due-num-weeks-warning-min').show()
BaseModal.prototype.disableActionButton.call(this.parent, 'save');
}
else {
this.$('#due-num-weeks-warning-max').hide();
this.$('#due-num-weeks-warning-min').hide();
BaseModal.prototype.enableActionButton.call(this.parent, 'save');
}
},
clearValue: function(event) {
event.preventDefault();
this.$('#due_in').val('');
},
afterRender: function() {
AbstractEditor.prototype.afterRender.call(this);
this.$('.field-due-in input').val(this.model.get('due_num_weeks'));
},
getRequestData: function() {
if (this.getValue() < 19 && this.getValue() > 0) {
return {
metadata: {
due_num_weeks: this.getValue()
}
};
}
}
});
ReleaseDateEditor = BaseDateEditor.extend({
fieldName: 'start',
templateName: 'release-date-editor',
@@ -1077,6 +1129,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
} else if (xblockInfo.isSequential()) {
tabs[0].editors = [ReleaseDateEditor, GradingEditor, DueDateEditor];
tabs[1].editors = [ContentVisibilityEditor, ShowCorrectnessEditor];
if (course.get('self_paced') && course.get('is_custom_pls_active')) {
tabs[0].editors.push(SelfPacedDueDateEditor);
}
if (options.enable_proctored_exams || options.enable_timed_exams) {
advancedTab.editors.push(TimedExaminationPreferenceEditor);

View File

@@ -10,6 +10,7 @@
<%!
from django.utils.translation import ugettext as _
from cms.djangoapps.contentstore.config.waffle import CUSTOM_PLS
from lms.djangoapps.branding import api as branding_api
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.js_utils import (
@@ -155,7 +156,8 @@ from openedx.core.release import RELEASE_LINE
num: "${context_course.location.course | n, js_escaped_string}",
display_course_number: "${context_course.display_coursenumber | n, js_escaped_string}",
revision: "${context_course.location.branch | n, js_escaped_string}",
self_paced: ${ context_course.self_paced | n, dump_js_escaped_json }
self_paced: ${ context_course.self_paced | n, dump_js_escaped_json },
is_custom_pls_active: ${CUSTOM_PLS.is_enabled(context_course.id) | n, dump_js_escaped_json}
});
</script>
% endif

View File

@@ -29,7 +29,7 @@ from django.urls import reverse
<%block name="header_extras">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs', 'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor', 'course-highlights-enable']:
% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'self-paced-due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs', 'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor', 'course-highlights-enable']:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>

View File

@@ -0,0 +1,25 @@
<ul class="list-fields list-input date-setter">
<li class="field field-text field-due-in">
<label for="due_in"><%- gettext('Due in:') %></label>
<input type="number" id="due_in" name="due_in" value=""
placeholder="" autocomplete="off" min="1" max="18"/> weeks
</li>
</ul>
<ul class="list-actions">
<li class="action-item">
<a href="#" data-tooltip="<%- gettext('Clear Due Date') %>" class="clear-date action-button action-clear">
<span class="icon fa fa-undo" aria-hidden="true"></span>
<span class="sr"><%- gettext('Clear Due Date') %></span>
</a>
</li>
</ul>
<div id="due-num-weeks-warning-max" class="message-status error">
<%- gettext('The maximum number of weeks this subsection can be due in is 18 weeks.') %>
</div>
<div id="due-num-weeks-warning-min" class="message-status error">
<%- gettext('The minimum number of weeks this subsection can be due in is 1 week.') %>
</div>

View File

@@ -46,6 +46,12 @@ class InheritanceMixin(XBlockMixin):
help=_("Enter the default date by which problems are due."),
scope=Scope.settings,
)
# This attribute is for custom pacing in self paced courses for Studio if CUSTOM_PLS flag is active
due_num_weeks = Integer(
display_name=_("Number of Weeks Due By"),
help=_("Enter the number of weeks the problems are due by relative to the learner's start date"),
scope=Scope.settings,
)
visible_to_staff_only = Boolean(
help=_("If true, can be seen only by course staff, regardless of start date."),
default=False,

View File

@@ -1051,7 +1051,7 @@ def allowed_metadata_by_category(category):
return {
'vertical': [],
'chapter': ['start'],
'sequential': ['due', 'format', 'start', 'graded']
'sequential': ['due', 'due_num_weeks', 'format', 'start', 'graded']
}.get(category, ['*'])

View File

@@ -78,6 +78,12 @@ class SequenceFields: # lint-amnesty, pylint: disable=missing-class-docstring
help=_("Enter the date by which problems are due."),
scope=Scope.settings,
)
# This attribute is for custom pacing in self paced courses for Studio if CUSTOM_PLS flag is active
due_num_weeks = Integer(
display_name=_("Number of Weeks Due By"),
help=_("Enter the number of weeks the problems are due by relative to the learner's start date"),
scope=Scope.settings,
)
hide_after_due = Boolean(
display_name=_("Hide sequence content After Due Date"),

View File

@@ -1,16 +1,18 @@
"""Signal handlers for writing course dates into edx_when."""
from datetime import timedelta
import logging
from django.dispatch import receiver
from edx_when.api import FIELDS_TO_EXTRACT, set_dates_for_course
from xblock.fields import Scope
from xmodule.util.misc import is_xblock_an_assignment
from cms.djangoapps.contentstore.config.waffle import CUSTOM_PLS
from openedx.core.lib.graph_traversals import get_children, leaf_filter, traverse_pre_order
from xblock.fields import Scope # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import SignalHandler, modulestore
from xmodule.util.misc import is_xblock_an_assignment
from .models import SelfPacedRelativeDatesConfig
from .utils import spaced_out_sections
@@ -78,6 +80,21 @@ def _gather_graded_items(root, due): # lint-amnesty, pylint: disable=missing-fu
return []
def _get_custom_pacing_children(subsection, num_weeks):
"""
Return relative date items for the subsection and its children
"""
items = [subsection]
section_date_items = []
while items:
next_item = items.pop()
# Open response assessment problems have their own due dates
if next_item.category != 'openassessment':
section_date_items.append((next_item.location, {'due': timedelta(weeks=num_weeks)}))
items.extend(next_item.get_children())
return section_date_items
def extract_dates_from_course(course):
"""
Extract all dates from the supplied course.
@@ -95,9 +112,17 @@ def extract_dates_from_course(course):
for _, section, weeks_to_complete in spaced_out_sections(course):
section_date_items = []
for subsection in section.get_children():
section_date_items.extend(_gather_graded_items(subsection, weeks_to_complete))
if section_date_items and section.graded:
# If custom pacing is set on a subsection, apply the set relative
# date to all the content inside the subsection. Otherwise
# apply the default Personalized Learner Schedules (PLS)
# logic for self paced courses.
due_num_weeks = subsection.fields['due_num_weeks'].read_from(subsection)
if (CUSTOM_PLS.is_enabled(course.id) and due_num_weeks):
section_date_items.extend(_get_custom_pacing_children(subsection, due_num_weeks))
else:
section_date_items.extend(_gather_graded_items(subsection, weeks_to_complete))
# if custom pls is active, we will allow due dates to be set for ungraded items as well
if section_date_items and (section.graded or CUSTOM_PLS.is_enabled(course.id)):
date_items.append((section.location, weeks_to_complete))
date_items.extend(section_date_items)
else:

View File

@@ -1,16 +1,22 @@
# lint-amnesty, pylint: disable=missing-module-docstring
from datetime import timedelta
import ddt
from unittest.mock import patch # lint-amnesty, pylint: disable=wrong-import-order
from openedx.core.djangoapps.course_date_signals.handlers import _gather_graded_items, _has_assignment_blocks
from cms.djangoapps.contentstore.config.waffle import CUSTOM_PLS
from edx_toggles.toggles.testutils import override_waffle_flag
from openedx.core.djangoapps.course_date_signals.handlers import (
_gather_graded_items,
_get_custom_pacing_children,
_has_assignment_blocks,
extract_dates_from_course
)
from openedx.core.djangoapps.course_date_signals.models import SelfPacedRelativeDatesConfig
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from . import utils
@ddt.ddt
class SelfPacedDueDatesTests(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
def setUp(self):
super().setUp()
@@ -91,7 +97,6 @@ class SelfPacedDueDatesTests(ModuleStoreTestCase): # lint-amnesty, pylint: disa
with modulestore().bulk_operations(self.course.id):
sequence = ItemFactory(parent=self.course, category="sequential")
vertical = ItemFactory(parent=sequence, category="vertical")
sequence = modulestore().get_item(sequence.location)
ItemFactory.create(
parent=vertical,
category='problem',
@@ -114,5 +119,218 @@ class SelfPacedDueDatesTests(ModuleStoreTestCase): # lint-amnesty, pylint: disa
(ungraded_problem_2.location, {'due': None}),
(graded_problem_1.location, {'due': 5}),
]
sequence = modulestore().get_item(sequence.location)
self.assertCountEqual(_gather_graded_items(sequence, 5), expected_graded_items)
def test_get_custom_pacing_children(self):
"""
_get_custom_pacing_items should return a list of (block item location, field metadata dictionary)
where the due dates are set from due_num_weeks
"""
# A subsection with multiple units but no problems. Units should inherit due date.
with modulestore().bulk_operations(self.course.id):
sequence = ItemFactory(parent=self.course, category='sequential', due_num_weeks=2)
vertical1 = ItemFactory(parent=sequence, category='vertical')
vertical2 = ItemFactory(parent=sequence, category='vertical')
vertical3 = ItemFactory(parent=sequence, category='vertical')
expected_dates = [
(sequence.location, {'due': timedelta(weeks=2)}),
(vertical1.location, {'due': timedelta(weeks=2)}),
(vertical2.location, {'due': timedelta(weeks=2)}),
(vertical3.location, {'due': timedelta(weeks=2)})
]
self.assertCountEqual(_get_custom_pacing_children(sequence, 2), expected_dates)
# A subsection with multiple units, each of which has a problem.
# Problems should also inherit due date.
problem1 = ItemFactory(parent=vertical1, category='problem')
problem2 = ItemFactory(parent=vertical2, category='problem')
expected_dates.extend([
(problem1.location, {'due': timedelta(weeks=2)}),
(problem2.location, {'due': timedelta(weeks=2)})
])
sequence = modulestore().get_item(sequence.location)
self.assertCountEqual(_get_custom_pacing_children(sequence, 2), expected_dates)
# A subsection that has ORA as a problem. ORA should not inherit due date.
ItemFactory.create(parent=vertical3, category='openassessment')
sequence = modulestore().get_item(sequence.location)
self.assertCountEqual(_get_custom_pacing_children(sequence, 2), expected_dates)
# A subsection that has an ORA problem and a non ORA problem. ORA should
# not inherit due date, but non ORA problems should.
problem3 = ItemFactory(parent=vertical3, category='problem')
expected_dates.append((problem3.location, {'due': timedelta(weeks=2)}))
sequence = modulestore().get_item(sequence.location)
self.assertCountEqual(_get_custom_pacing_children(sequence, 2), expected_dates)
class SelfPacedCustomDueDateTests(SharedModuleStoreTestCase):
"""
Tests the custom Personalized Learner Schedule (PLS) dates in self paced courses
"""
def setUp(self):
super().setUp()
SelfPacedRelativeDatesConfig.objects.create(enabled=True)
# setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
self.course = CourseFactory.create(self_paced=True)
self.chapter = ItemFactory.create(category='chapter', parent=self.course)
@override_waffle_flag(CUSTOM_PLS, active=True)
def test_extract_dates_from_course_inheritance(self):
"""
extract_dates_from_course should return a list of (block item location, field metadata dictionary)
and the blocks should inherit the dates from those above in the hiearchy
(ex. If a subsection is assigned a due date, its children should also have the same due date)
"""
course = self.course
with self.store.bulk_operations(course.id):
sequential = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=3)
vertical = ItemFactory.create(category='vertical', parent=sequential)
problem = ItemFactory.create(category='problem', parent=vertical)
expected_dates = [
(course.location, {}),
(self.chapter.location, timedelta(days=28)),
(sequential.location, {'due': timedelta(days=21)}),
(vertical.location, {'due': timedelta(days=21)}),
(problem.location, {'due': timedelta(days=21)})
]
course = modulestore().get_item(course.location)
self.assertCountEqual(extract_dates_from_course(course), expected_dates)
@override_waffle_flag(CUSTOM_PLS, active=True)
def test_extract_dates_from_course_custom_and_default_pls_one_subsection(self):
"""
due_num_weeks in one of the subsections. Only one of them should have a set due date.
The other subsections do not have due dates because they are not graded
and default PLS do not assign due dates to non graded assignments.
If custom PLS is not set, the subsection will fall back to the default
PLS logic of evenly spaced sections.
"""
course = self.course
with self.store.bulk_operations(course.id):
sequential = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=3)
ItemFactory.create(category='sequential', parent=self.chapter)
ItemFactory.create(category='sequential', parent=self.chapter)
expected_dates = [
(course.location, {}),
(self.chapter.location, timedelta(days=28)),
(sequential.location, {'due': timedelta(days=21)})
]
course = modulestore().get_item(course.location)
self.assertCountEqual(extract_dates_from_course(course), expected_dates)
@override_waffle_flag(CUSTOM_PLS, active=True)
def test_extract_dates_from_course_custom_and_default_pls_one_subsection_graded(self):
"""
A section with a subsection that has due_num_weeks and
a subsection without due_num_weeks that has graded content.
Default PLS should apply for the subsection without due_num_weeks that has graded content.
If custom PLS is not set, the subsection will fall back to the default
PLS logic of evenly spaced sections.
"""
course = self.course
with self.store.bulk_operations(course.id):
sequential1 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=2)
vertical1 = ItemFactory.create(category='vertical', parent=sequential1)
problem1 = ItemFactory.create(category='problem', parent=vertical1)
chapter2 = ItemFactory.create(category='chapter', parent=course)
sequential2 = ItemFactory.create(category='sequential', parent=chapter2, graded=True)
vertical2 = ItemFactory.create(category='vertical', parent=sequential2)
problem2 = ItemFactory.create(category='problem', parent=vertical2)
expected_dates = [
(course.location, {}),
(self.chapter.location, timedelta(days=21)),
(sequential1.location, {'due': timedelta(days=14)}),
(vertical1.location, {'due': timedelta(days=14)}),
(problem1.location, {'due': timedelta(days=14)}),
(chapter2.location, timedelta(days=42)),
(sequential2.location, {'due': timedelta(days=42)}),
(vertical2.location, {'due': timedelta(days=42)}),
(problem2.location, {'due': timedelta(days=42)})
]
course = modulestore().get_item(course.location)
with patch.object(utils, 'get_expected_duration', return_value=timedelta(weeks=6)):
self.assertCountEqual(extract_dates_from_course(course), expected_dates)
@override_waffle_flag(CUSTOM_PLS, active=True)
def test_extract_dates_from_course_custom_and_default_pls_multiple_subsections_graded(self):
"""
A section with a subsection that has due_num_weeks and multiple sections without
due_num_weeks that has graded content. Default PLS should apply for the subsections
without due_num_weeks that has graded content.
If custom PLS is not set, the subsection will fall back to the default
PLS logic of evenly spaced sections.
"""
course = self.course
with self.store.bulk_operations(course.id):
sequential1 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=4)
vertical1 = ItemFactory.create(category='vertical', parent=sequential1)
problem1 = ItemFactory.create(category='problem', parent=vertical1)
expected_dates = [
(course.location, {}),
(self.chapter.location, timedelta(days=14)),
(sequential1.location, {'due': timedelta(days=28)}),
(vertical1.location, {'due': timedelta(days=28)}),
(problem1.location, {'due': timedelta(days=28)})
]
for i in range(3):
chapter = ItemFactory.create(category='chapter', parent=course)
sequential = ItemFactory.create(category='sequential', parent=chapter, graded=True)
vertical = ItemFactory.create(category='vertical', parent=sequential)
problem = ItemFactory.create(category='problem', parent=vertical)
num_days = i * 14 + 28
expected_dates.extend([
(chapter.location, timedelta(days=num_days)),
(sequential.location, {'due': timedelta(days=num_days)}),
(vertical.location, {'due': timedelta(days=num_days)}),
(problem.location, {'due': timedelta(days=num_days)}),
])
course = modulestore().get_item(course.location)
with patch.object(utils, 'get_expected_duration', return_value=timedelta(weeks=8)):
self.assertCountEqual(extract_dates_from_course(course), expected_dates)
@override_waffle_flag(CUSTOM_PLS, active=True)
def test_extract_dates_from_course_all_subsections(self):
"""
With due_num_weeks on all subsections. All subsections should
have their corresponding due dates.
"""
course = self.course
with self.store.bulk_operations(course.id):
sequential1 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=3)
sequential2 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=4)
sequential3 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=5)
expected_dates = [
(course.location, {}),
(self.chapter.location, timedelta(days=28)),
(sequential1.location, {'due': timedelta(days=21)}),
(sequential2.location, {'due': timedelta(days=28)}),
(sequential3.location, {'due': timedelta(days=35)})
]
course = modulestore().get_item(course.location)
self.assertCountEqual(extract_dates_from_course(course), expected_dates)
@override_waffle_flag(CUSTOM_PLS, active=True)
def test_extract_dates_from_course_no_subsections(self):
"""
Without due_num_weeks on all subsections. None of the subsections should
have due dates.
"""
course = self.course
with self.store.bulk_operations(course.id):
for _ in range(3):
ItemFactory.create(category='sequential', parent=self.chapter)
expected_dates = [(course.location, {})]
course = modulestore().get_item(course.location)
self.assertCountEqual(extract_dates_from_course(course), expected_dates)