feat: AA-883 basic prototype for custom pacing pls in studio
fix: make new field in xblock json serializable and don't assign due dates to ORAs feat: display warning message in Studio if the relative date input is more than 18 weeks for custom pacing in self paced course fix: handle due dates for mix of ORA and non ORA problems under a subsection and other styling fixes feat: add a minimum restriction for self paced courses due date editor input fix: naming of warning id divs to be more specific and exclude children of ORA problems in setting due dates test: extracting dates for a self paced course with custom pacing test: frontend for self paced custom pacing modal in studio and clean up its backend tests fix: remove an unused line when getting children of custom pacing subsection, reorganize testing for custom pacing fix: more specific comments to testing for custom PLS and remove a test case course fix: more cleanup for self paced custom pacing PLS backend tests
This commit is contained in:
@@ -69,31 +69,13 @@ REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND = LegacyWaffleFlag(
|
||||
)
|
||||
|
||||
|
||||
# .. toggle_name: studio.pages_and_resources_mfe
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Waffle flag to link existing studio views to the new Pages and Resources experience.
|
||||
# .. toggle_use_cases: temporary, open_edx
|
||||
# .. toggle_creation_date: 2021-05-24
|
||||
# .. toggle_target_removal_date: 2021-12-31
|
||||
# .. toggle_warnings: Also set settings.COURSE_AUTHORING_MICROFRONTEND_URL.
|
||||
# .. toggle_tickets: None
|
||||
ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND = CourseWaffleFlag(
|
||||
waffle_namespace=waffle_flags(),
|
||||
flag_name='pages_and_resources_mfe',
|
||||
module_name=__name__,
|
||||
)
|
||||
|
||||
# .. toggle_name: studio.custom_pls
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False (except for SuperUsers)
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Waffle flag to enable custom pacing for PLS
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2021-06-15
|
||||
# .. toggle_creation_date: 2021-07-08
|
||||
# .. toggle_target_removal_date: 2021-12-31
|
||||
# .. toggle_warnings: None
|
||||
# .. toggle_tickets: None
|
||||
# .. 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__,)
|
||||
|
||||
def custom_pls_is_active(course_key):
|
||||
return CUSTOM_PLS.is_enabled(course_key)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
@@ -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
|
||||
|
||||
@@ -15,10 +15,9 @@ 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,
|
||||
SelfPacedDueDateEditor;
|
||||
AccessEditor, ShowCorrectnessEditor, HighlightsEditor, HighlightsEnableXBlockModal, HighlightsEnableEditor;
|
||||
|
||||
CourseOutlineXBlockModal = BaseModal.extend({
|
||||
events: _.extend({}, BaseModal.prototype.events, {
|
||||
@@ -75,7 +74,6 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
||||
|
||||
event.preventDefault();
|
||||
requestData = this.getRequestData();
|
||||
console.log(requestData)
|
||||
if (!_.isEqual(requestData, {metadata: {}})) {
|
||||
XBlockViewUtils.updateXBlockFields(this.model, requestData, {
|
||||
success: this.options.onSave
|
||||
@@ -391,32 +389,55 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
||||
}
|
||||
});
|
||||
|
||||
SelfPacedDueDateEditor = BaseDateEditor.extend({
|
||||
fieldName: 'due',
|
||||
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 this.$('#due_date').val();
|
||||
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_date').val('');
|
||||
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() {
|
||||
let currentDate = parseInt(this.getValue())
|
||||
if (parseInt(this.getValue())){
|
||||
currentDate = new Date()
|
||||
currentDate.setDate(currentDate.getDate() + parseInt(this.getValue())*7)
|
||||
};
|
||||
// due_num_weeks
|
||||
return {
|
||||
metadata: {
|
||||
due: currentDate
|
||||
}
|
||||
};
|
||||
if (this.getValue() < 19 && this.getValue() > 0) {
|
||||
return {
|
||||
metadata: {
|
||||
due_num_weeks: this.getValue()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1108,9 +1129,8 @@ 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')) {
|
||||
tabs[0].editors.push(SelfPacedDueDateEditor)
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
<ul class="list-fields list-input datepair date-setter">
|
||||
<li class="field field-text field-due-date">
|
||||
<label for="due_date"><%- gettext('Due in:') %></label>
|
||||
<input type="number" id="due_date" name="due_date" value=""
|
||||
placeholder="" autocomplete="off"/> weeks
|
||||
<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 Grading Due Date') %>" class="clear-date action-button action-clear">
|
||||
<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 Grading Due Date') %></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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,11 +78,11 @@ 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"),
|
||||
scope = Scope.settings,
|
||||
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(
|
||||
@@ -201,7 +201,6 @@ class ProctoringFields:
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
|
||||
def _get_course(self):
|
||||
"""
|
||||
|
||||
@@ -51,7 +51,6 @@ SUPPORTED_FIELDS = [
|
||||
SupportedFieldType('format'),
|
||||
SupportedFieldType('start'),
|
||||
SupportedFieldType('due'),
|
||||
SupportedFieldType('due_num_weeks'),
|
||||
SupportedFieldType('contains_gated_content'),
|
||||
SupportedFieldType('has_score'),
|
||||
SupportedFieldType('has_scheduled_content'),
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"""Signal handlers for writing course dates into edx_when."""
|
||||
|
||||
|
||||
from datetime import timedelta, datetime
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from cms.djangoapps.contentstore.config.waffle import custom_pls_is_active
|
||||
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
|
||||
@@ -30,11 +29,6 @@ def _field_values(fields, xblock):
|
||||
if field_name not in xblock.fields:
|
||||
continue
|
||||
field = xblock.fields[field_name]
|
||||
if field_name == 'due':
|
||||
print("THIS IS THE FIELD ", field)
|
||||
print(xblock)
|
||||
result[field.name] = field.read_from(xblock)
|
||||
continue
|
||||
if field.scope == Scope.settings and field.is_set_on(xblock):
|
||||
try:
|
||||
result[field.name] = field.read_from(xblock)
|
||||
@@ -86,39 +80,27 @@ 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.
|
||||
"""
|
||||
log.info('Extracting course dates for %s', course.id)
|
||||
|
||||
if course.self_paced and custom_pls_is_active(course.id):
|
||||
print("This is self paced ")
|
||||
date_items = []
|
||||
store = modulestore()
|
||||
with store.branch_setting(ModuleStoreEnum.Branch.published_only, course.id):
|
||||
items = store.get_items(course.id)
|
||||
log.info('Extracting dates from %d items in %s', len(items), course.id)
|
||||
print("B4 the items sections")
|
||||
# new_fields_to_extract = FIELDS_TO_EXTRACT + ('due_num_weeks',)
|
||||
# print("THe new fields to extract ", new_fields_to_extract)
|
||||
for item in items:
|
||||
metadata = _field_values(FIELDS_TO_EXTRACT, item)
|
||||
print("THIS IS THE METADATA ", metadata)
|
||||
metadata['due'] = datetime.datetime.now() - metadata['due']
|
||||
|
||||
# print("TYPE OF DATES: ", metadata)
|
||||
# print("RIGHT NOW, ", datetime.datetime.now())
|
||||
# print(metadata['due'])
|
||||
# metadata['due'] = datetime.datetime.now() - metadata['due']
|
||||
# print('metadata due: ', metadata['due'])
|
||||
metadata.pop('due_num_weeks',None)
|
||||
# print("THIS IS THE DUE DATE: ", metadata['due'])
|
||||
date_items.append((item.location, metadata))
|
||||
# date_items.append((item.location, _field_values(FIELDS_TO_EXTRACT, item)))
|
||||
print("Here are the date items: ", date_items)
|
||||
|
||||
elif course.self_paced and not custom_pls_is_active(course.id):
|
||||
if course.self_paced:
|
||||
metadata = _field_values(FIELDS_TO_EXTRACT, course)
|
||||
# self-paced courses may accidentally have a course due date
|
||||
metadata.pop('due', None)
|
||||
@@ -129,11 +111,18 @@ def extract_dates_from_course(course):
|
||||
# unless that item already has a relative date set
|
||||
for _, section, weeks_to_complete in spaced_out_sections(course):
|
||||
section_date_items = []
|
||||
print("THESE IS THE WEEKS TO COMPLETE ,", weeks_to_complete)
|
||||
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:
|
||||
@@ -144,7 +133,6 @@ def extract_dates_from_course(course):
|
||||
log.info('Extracting dates from %d items in %s', len(items), course.id)
|
||||
for item in items:
|
||||
date_items.append((item.location, _field_values(FIELDS_TO_EXTRACT, item)))
|
||||
|
||||
return date_items
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user