Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: optional xblocks #34275

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cms/djangoapps/contentstore/views/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
'group_access': xblock.group_access,
'user_partitions': user_partitions,
'show_correctness': xblock.show_correctness,
'optional_content': xblock.optional_content,
})

if xblock.category == 'sequential':
Expand Down
1 change: 1 addition & 0 deletions cms/djangoapps/models/settings/course_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class CourseMetadata:
'highlights_enabled_for_messaging',
'is_onboarding_exam',
'discussions_settings',
'optional',
]

@classmethod
Expand Down
46 changes: 43 additions & 3 deletions cms/static/js/views/modals/course_outline_modals.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
ReleaseDateEditor, DueDateEditor, SelfPacedDueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor,
StaffLockEditor, UnitAccessEditor, ContentVisibilityEditor, TimedExaminationPreferenceEditor,
AccessEditor, ShowCorrectnessEditor, HighlightsEditor, HighlightsEnableXBlockModal, HighlightsEnableEditor,
DiscussionEditor;
DiscussionEditor, OptionalContentEditor;

CourseOutlineXBlockModal = BaseModal.extend({
events: _.extend({}, BaseModal.prototype.events, {
Expand Down Expand Up @@ -1206,6 +1206,46 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
}
});

OptionalContentEditor = AbstractEditor.extend(
{
templateName: 'optional-content-editor',
className: 'edit-optional-content',

afterRender: function() {
AbstractEditor.prototype.afterRender.call(this);
this.setValue(this.model.get("optional_content"));
},

setValue: function(value) {
this.$('input[name=optional_content]').prop('checked', value);
},

currentValue: function() {
return this.$('input[name=optional_content]').is(':checked');
},

hasChanges: function() {
return this.model.get('optional_content') !== this.currentValue();
},

getRequestData: function() {
if (this.hasChanges()) {
return {
publish: 'republish',
metadata: {
optional_content: this.currentValue()
}
};
} else {
return {};
}
},
getContext: function() {
return {
optional_content: this.model.get('optional_content')
};
},
})
return {
getModal: function(type, xblockInfo, options) {
if (type === 'edit') {
Expand Down Expand Up @@ -1245,10 +1285,10 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
}
];
if (xblockInfo.isChapter()) {
tabs[0].editors = [ReleaseDateEditor];
tabs[0].editors = [ReleaseDateEditor, OptionalContentEditor];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we show the editor only when the completion is enabled (ENABLE_COMPLETION_TRACKING_SWITCH.is_enabled())?

Copy link
Contributor Author

@DanielVZ96 DanielVZ96 Feb 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Agrendalath I'm not sure how to implement that. can we do that in a followup ticket?

tabs[1].editors = [StaffLockEditor];
} else if (xblockInfo.isSequential()) {
tabs[0].editors = [ReleaseDateEditor, GradingEditor, DueDateEditor];
tabs[0].editors = [ReleaseDateEditor, GradingEditor, DueDateEditor, OptionalContentEditor];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should allow overriding this in Units as well.

tabs[1].editors = [ContentVisibilityEditor, ShowCorrectnessEditor];
if (course.get('self_paced') && course.get('is_custom_relative_dates_active')) {
tabs[0].editors.push(SelfPacedDueDateEditor);
Expand Down
8 changes: 6 additions & 2 deletions cms/static/sass/elements/_modal-window.scss
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@

.edit-discussion,
.edit-staff-lock,
.edit-optional-content,
.edit-content-visibility,
.edit-unit-access {
margin-bottom: $baseline;
Expand All @@ -760,19 +761,20 @@
// UI: staff lock and discussion
.edit-discussion,
.edit-staff-lock,
.edit-optional-content,
.edit-settings-timed-examination,
.edit-unit-access {
.checkbox-cosmetic .input-checkbox {
@extend %cont-text-sr;

// CASE: unchecked
~ .tip-warning {
~.tip-warning {
display: block;
}

// CASE: checked
&:checked {
~ .tip-warning {
~.tip-warning {
Comment on lines -769 to +777
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not seem to be right. Why did we change it?

Copy link
Contributor Author

@DanielVZ96 DanielVZ96 Feb 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, autoformatting strikes again, will revert

display: none;
}
}
Expand Down Expand Up @@ -832,6 +834,7 @@

.edit-discussion,
.edit-unit-access,
.edit-optional-content,
.edit-staff-lock {
.modal-section-content {
@include font-size(16);
Expand Down Expand Up @@ -874,6 +877,7 @@

.edit-discussion,
.edit-unit-access,
.edit-optional-content,
.edit-staff-lock {
.modal-section-content {
@include font-size(16);
Expand Down
2 changes: 1 addition & 1 deletion cms/templates/course_outline.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

<%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', 'self-paced-due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-editor', 'discussion-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', 'discussion-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', 'optional-content-editor']:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
Expand Down
3 changes: 3 additions & 0 deletions cms/templates/js/course-outline.underscore
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ if (is_proctored_exam) {
<% if (xblockInfo.get('release_date')) { %>
<%- xblockInfo.get('release_date') %>
<% } %>
<% if (xblockInfo.get('optional_content')) { %>
- <%- gettext('Optional') %>
<% } %>
<% } %>
</span>
</p>
Expand Down
5 changes: 5 additions & 0 deletions cms/templates/js/optional-content-editor.underscore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<h3 class="modal-section-title">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole editor is a h3 tag. The heading could simply be Completion instead. See the cms/static/templates/grading-editor.underscore as an example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

modified in the other PR to look like this.
optional-editor

<input type="checkbox" id="optional_content" name="optional_content"
class="input input-checkbox" />
<%- gettext('Mark as optional') %>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add an explanation (perhaps with the .field-message class) to this field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

modified in the other PR to look like this
optional-editor-disabled

</h3>
1 change: 1 addition & 0 deletions lms/djangoapps/course_api/blocks/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def __init__(
SupportedFieldType('has_score'),
SupportedFieldType('has_scheduled_content'),
SupportedFieldType('weight'),
SupportedFieldType('optional_content'),
SupportedFieldType('show_correctness'),
# 'student_view_data'
SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_DATA, StudentViewTransformer),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def get_block_completion(cls, block_structure, block_key):

@classmethod
def collect(cls, block_structure):
block_structure.request_xblock_fields('completion_mode')
block_structure.request_xblock_fields('completion_mode', 'optional_content')

@staticmethod
def _is_block_excluded(block_structure, block_key):
Expand Down
1 change: 1 addition & 0 deletions lms/djangoapps/course_home_api/outline/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def get_blocks(self, block): # pylint: disable=missing-function-docstring
'resume_block': block.get('resume_block', False),
'type': block_type,
'has_scheduled_content': block.get('has_scheduled_content'),
'optional_content': block.get('optional_content'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please read this:

# All Transformers are expected to maintain version-related class
# attributes. While the values for the base class is set to 0,
# the values for each concrete transformer should be 1 or higher.
#
# A transformer's version attributes are used by the block_structure
# framework in order to determine whether any collected data for a
# transformer is outdated because of a data schema change by the
# transformer.
#
# The WRITE_VERSION number is stored along with the transformer's
# data when it is collected and cached (during the collect phase).
# The READ_VERSION number is then verified to be less than or equal
# to the version associated with the collected data when the
# collected data is accessed (during the transform phase).
#
# We distinguish between WRITE_VERSION and READ_VERSION numbers in
# order to:
# 1. support blue-green deployments where new and previous versions
# of the code base are simultaneously executing on different
# workers for a period of time.
#
# A 2-phase deployment is used to stagger read and write changes.
#
# 2. scale for large deployments where it is costly to recompute
# block structures for all courses when a transformer's collected
# data schema changes.
#
# A background management command is run to prime the new data.
#
# See the following document for further information:
# https://openedx.atlassian.net/wiki/display/MA/Block+Structure+Cache+Invalidation+Proposal
#
# The WRITE_VERSION number of a Transformer should be incremented
# when it's collect implementation is additively changed. Backward
# compatibility should be maintained with previous READ_VERSIONs
# until all readers are updated.
#
# The READ_VERSION number of a Transformer should be incremented
# when its transform implementation is updated to make use of the
# newly collected data - and released only after all collected
# block structures are updated with the new WRITE_VERSION.
#

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Agrendalath so based on that I should bump the block_completion transformer write version, and after we're sure all blocks have the new version, we bump the read version?

},
}
for child in children:
Expand Down
15 changes: 15 additions & 0 deletions lms/djangoapps/course_home_api/outline/tests/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,18 @@ def test_cannot_enroll_if_full(self):
self.update_course_and_overview()
CourseEnrollment.enroll(UserFactory(), self.course.id) # grr, some rando took our spot!
self.assert_can_enroll(False)

def test_optional_content(self):
CourseEnrollment.enroll(self.user, self.course.id)
assert not self.course.optional_content
response = self.client.get(self.url)
for block in response.data['course_blocks']['blocks'].values():
assert not block['optional_content']

def test_optional_content_true(self):
self.course.optional_content = True
self.update_course_and_overview()
CourseEnrollment.enroll(self.user, self.course.id)
response = self.client.get(self.url)
for block in response.data['course_blocks']['blocks'].values():
assert block['optional_content']
1 change: 1 addition & 0 deletions lms/djangoapps/course_home_api/progress/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ class ProgressTabSerializer(VerifiedModeSerializer):
access_expiration = serializers.DictField()
certificate_data = CertificateDataSerializer()
completion_summary = serializers.DictField()
optional_completion_summary = serializers.DictField()
course_grade = CourseGradeSerializer()
credit_course_requirements = serializers.DictField()
end = serializers.DateTimeField()
Expand Down
10 changes: 10 additions & 0 deletions lms/djangoapps/course_home_api/progress/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,13 @@ def test_course_grade_considers_subsection_grade_visibility(self, is_staff, expe
assert response.status_code == 200
assert response.data['course_grade']['percent'] == expected_percent
assert response.data['course_grade']['is_passing'] == (expected_percent >= 0.5)

def test_optional_content(self):
CourseEnrollment.enroll(self.user, self.course.id)
response = self.client.get(self.url)
assert response.status_code == 200
assert response.data['optional_completion_summary'] == {
'complete_count': 0,
'incomplete_count': 0,
'locked_count': 0,
}
1 change: 1 addition & 0 deletions lms/djangoapps/course_home_api/progress/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ def get(self, request, *args, **kwargs):
'access_expiration': access_expiration,
'certificate_data': get_cert_data(student, course, enrollment_mode, course_grade),
'completion_summary': get_course_blocks_completion_summary(course_key, student),
'optional_completion_summary': get_course_blocks_completion_summary(course_key, student, optional=True),
'course_grade': course_grade,
'credit_course_requirements': credit_course_requirements(course_key, student),
'end': course.end,
Expand Down
10 changes: 9 additions & 1 deletion lms/djangoapps/courseware/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None,


@request_cached()
def get_course_blocks_completion_summary(course_key, user):
def get_course_blocks_completion_summary(course_key, user, optional=False):
"""
Returns an object with the number of complete units, incomplete units, and units that contain gated content
for the given course. The complete and incomplete counts only reflect units that are able to be completed by
Expand All @@ -566,10 +566,18 @@ def get_course_blocks_completion_summary(course_key, user):
course_usage_key = store.make_course_usage_key(course_key)
block_data = get_course_blocks(user, course_usage_key, allow_start_dates_in_future=True, include_completion=True)

def _is_optional(*keys):
for key in keys:
if block_data.get_xblock_field(key, 'optional_content', False):
return True
return False

complete_count, incomplete_count, locked_count = 0, 0, 0
for section_key in block_data.get_children(course_usage_key): # pylint: disable=too-many-nested-blocks
for subsection_key in block_data.get_children(section_key):
for unit_key in block_data.get_children(subsection_key):
if optional != _is_optional(section_key, subsection_key, unit_key):
continue
Comment on lines +579 to +580
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the section sets the value of the optional_content field to True but the subsection sets it to False? Would it make sense to disallow marking subsection as non-optional when its parent section is marked as optional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was handling it as if a parent was optional all children would behave as optional regardless of their actual value, but now that you mention it, in order to reduce confusion it'd be better to disable the checkbox when a parent is optional.

complete = block_data.get_xblock_field(unit_key, 'complete', False)
contains_gated_content = block_data.get_xblock_field(unit_key, 'contains_gated_content', False)
if contains_gated_content:
Expand Down
1 change: 1 addition & 0 deletions openedx/features/course_experience/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ def recurse_mark_auth_denial(block):
'completion',
'complete',
'resume_block',
'optional_content',
],
allow_start_dates_in_future=allow_start_dates_in_future,
)
Expand Down
11 changes: 11 additions & 0 deletions xmodule/modulestore/inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ class InheritanceMixin(XBlockMixin):
scope=Scope.settings
)

optional_content = Boolean(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something similar to optional_completion would be better? The current name feels like this is related to grading, not the completion.

Same with the display_name, the name of the new Studio editor, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea. will rename it.

display_name=_('Optional'),
help=_(
'Set this to true to mark this block as optional.'
'Progress in this block won\'t count towards course completion progress'
'and will count as optional progress instead.'
),
default=False,
scope=Scope.settings,
)

@property
def close_date(self):
"""
Expand Down
Loading