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:
Sofia Yoon
2021-06-23 16:04:07 -04:00
parent 4d96449134
commit 09eb36e550
12 changed files with 506 additions and 115 deletions

View File

@@ -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)

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

@@ -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,

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});
@@ -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,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) {

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

@@ -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>

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

@@ -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):
"""

View File

@@ -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'),

View File

@@ -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

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)