<%- gettext('It is visible to learners, ensuring that all learners can view its contents.') %>
+diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d6fd594511fc..3a60d0041771 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -20,7 +20,7 @@ openedx/core/djangoapps/credit @openedx/2U- openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/oauth_dispatch openedx/core/djangoapps/user_api/ @openedx/2U-aperture -openedx/core/djangoapps/user_authn/ +openedx/core/djangoapps/user_authn/ @openedx/2U-vanguards openedx/features/course_experience/ xmodule/ diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py index 8f431fa49327..b10566bb04cb 100644 --- a/cms/djangoapps/contentstore/config/waffle.py +++ b/cms/djangoapps/contentstore/config/waffle.py @@ -53,3 +53,16 @@ # .. toggle_warning: Flag course_experience.relative_dates should also be active for relative dates functionalities to work. # .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844 CUSTOM_RELATIVE_DATES = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.custom_relative_dates', __name__) + + +# .. toggle_name: studio.enable_course_update_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable course update notifications. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 14-Feb-2024 +# .. toggle_target_removal_date: 14-Mar-2024 +ENABLE_COURSE_UPDATE_NOTIFICATIONS = CourseWaffleFlag( + f'{WAFFLE_NAMESPACE}.enable_course_update_notifications', + __name__ +) diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 8f59998e716c..77a6a00c4b58 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -19,7 +19,8 @@ from django.http import HttpResponseBadRequest from django.utils.translation import gettext as _ -from cms.djangoapps.contentstore.utils import track_course_update_event +from cms.djangoapps.contentstore.config.waffle import ENABLE_COURSE_UPDATE_NOTIFICATIONS +from cms.djangoapps.contentstore.utils import track_course_update_event, send_course_update_notification from openedx.core.lib.xblock_utils import get_course_update_items from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -43,7 +44,7 @@ def get_course_updates(location, provided_id, user_id): return _get_visible_update(course_update_items) -def update_course_updates(location, update, passed_id=None, user=None): +def update_course_updates(location, update, passed_id=None, user=None, request_method=None): """ Either add or update the given course update. Add: @@ -86,8 +87,17 @@ def update_course_updates(location, update, passed_id=None, user=None): # update db record save_course_update_items(location, course_updates, course_update_items, user) - # track course update event - track_course_update_event(location.course_key, user, course_update_dict) + + if request_method == "POST": + # track course update event + track_course_update_event(location.course_key, user, course_update_dict) + + # send course update notification + if ENABLE_COURSE_UPDATE_NOTIFICATIONS.is_enabled(location.course_key): + send_course_update_notification( + location.course_key, course_update_dict["content"], user, + ) + # remove status key if "status" in course_update_dict: del course_update_dict["status"] diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py index 4549326c9696..e95a76e91899 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py @@ -77,3 +77,4 @@ class XblockSerializer(StrictSerializer): target_index = serializers.IntegerField(required=False, allow_null=True) boilerplate = serializers.JSONField(required=False, allow_null=True) staged_content = serializers.CharField(required=False, allow_null=True) + hide_from_toc = serializers.BooleanField(required=False, allow_null=True) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 9f90840156ca..e09ff48834db 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1445,6 +1445,23 @@ def test_create_block(self): ).replace('REPLACE', r'([0-9]|[a-f]){3,}') self.assertRegex(data['locator'], retarget) + @ddt.data(True, False) + def test_hide_xblock_from_toc_via_handler(self, hide_from_toc): + """Test that the hide_from_toc field can be set via the xblock_handler.""" + course = CourseFactory.create() + sequential = BlockFactory.create(parent_location=course.location) + data = { + "metadata": { + "hide_from_toc": hide_from_toc + } + } + + response = self.client.ajax_post(get_url("xblock_handler", sequential.location), data) + sequential = self.store.get_item(sequential.location) + + self.assertEqual(response.status_code, 200) + self.assertEqual(hide_from_toc, sequential.hide_from_toc) + def test_capa_block(self): """Test that a problem treats markdown specially.""" course = CourseFactory.create() diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index c72a7e3c3aa0..e186b0754a64 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,6 +4,7 @@ from __future__ import annotations import configparser import logging +import re from collections import defaultdict from contextlib import contextmanager from datetime import datetime, timezone @@ -22,6 +23,9 @@ from opaque_keys.edx.locator import LibraryLocator from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED +from openedx_events.learning.data import CourseNotificationData +from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED + from milestones import api as milestones_api from pytz import UTC from xblock.fields import Scope @@ -62,6 +66,7 @@ from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.lib.courses import course_image_url +from openedx.core.lib.html_to_text import html_to_text from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML @@ -1951,3 +1956,27 @@ def track_course_update_event(course_key, user, event_data=None): context = contexts.course_context_from_course_id(course_key) with tracker.get_tracker().context(event_name, context): tracker.emit(event_name, event_data) + + +def send_course_update_notification(course_key, content, user): + """ + Send course update notification + """ + text_content = re.sub(r"(\s| |//)+", " ", html_to_text(content)) + course = modulestore().get_course(course_key) + extra_context = { + 'author_id': user.id, + 'course_name': course.display_name, + } + notification_data = CourseNotificationData( + course_key=course_key, + content_context={ + "course_update_content": text_content, + **extra_context, + }, + notification_type="course_update", + content_url=f"{settings.LMS_BASE}/courses/{str(course_key)}/course/updates", + app_name="updates", + audience_filters={}, + ) + COURSE_NOTIFICATION_REQUESTED.send_event(course_notification_data=notification_data) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index eac3c1048d7a..053514123825 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -1016,7 +1016,9 @@ def course_info_update_handler(request, course_key_string, provided_id=None): # can be either and sometimes django is rewriting one to the other: elif request.method in ('POST', 'PUT'): try: - return JsonResponse(update_course_updates(usage_key, request.json, provided_id, request.user)) + return JsonResponse(update_course_updates( + usage_key, request.json, provided_id, request.user, request.method + )) except: # lint-amnesty, pylint: disable=bare-except return HttpResponseBadRequest( "Failed to save", diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 44b89aa3a5f5..92f991b4704b 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -1089,6 +1089,8 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements "group_access": xblock.group_access, "user_partitions": user_partitions, "show_correctness": xblock.show_correctness, + "hide_from_toc": xblock.hide_from_toc, + "enable_hide_from_toc_ui": settings.FEATURES.get("ENABLE_HIDE_FROM_TOC_UI", False), } ) @@ -1217,6 +1219,15 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements else: xblock_info["staff_only_message"] = False + if xblock_info["hide_from_toc"]: + xblock_info["hide_from_toc_message"] = True + elif child_info and child_info["children"]: + xblock_info["hide_from_toc_message"] = all( + child["hide_from_toc_message"] for child in child_info["children"] + ) + else: + xblock_info["hide_from_toc_message"] = False + # If the ENABLE_TAGGING_TAXONOMY_LIST_PAGE feature flag is enabled, we show the "Manage Tags" options if use_tagging_taxonomy_list_page(): xblock_info["use_tagging_taxonomy_list_page"] = True @@ -1345,6 +1356,7 @@ class VisibilityState: needs_attention = "needs_attention" staff_only = "staff_only" gated = "gated" + hide_from_toc = "hide_from_toc" def _compute_visibility_state( @@ -1355,6 +1367,8 @@ def _compute_visibility_state( """ if xblock.visible_to_staff_only: return VisibilityState.staff_only + elif xblock.hide_from_toc: + return VisibilityState.hide_from_toc elif is_unit_with_changes: # Note that a unit that has never been published will fall into this category, # as well as previously published units with draft content. @@ -1362,22 +1376,27 @@ def _compute_visibility_state( is_unscheduled = xblock.start == DEFAULT_START_DATE is_live = is_course_self_paced or datetime.now(UTC) > xblock.start - if child_info and child_info.get("children", []): + if child_info and child_info.get("children", []): # pylint: disable=too-many-nested-blocks all_staff_only = True all_unscheduled = True all_live = True + all_hide_from_toc = True for child in child_info["children"]: child_state = child["visibility_state"] if child_state == VisibilityState.needs_attention: return child_state elif not child_state == VisibilityState.staff_only: all_staff_only = False - if not child_state == VisibilityState.unscheduled: - all_unscheduled = False - if not child_state == VisibilityState.live: - all_live = False + if not child_state == VisibilityState.hide_from_toc: + all_hide_from_toc = False + if not child_state == VisibilityState.unscheduled: + all_unscheduled = False + if not child_state == VisibilityState.live: + all_live = False if all_staff_only: return VisibilityState.staff_only + elif all_hide_from_toc: + return VisibilityState.hide_from_toc elif all_unscheduled: return ( VisibilityState.unscheduled diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 49812a6c9d78..2b82cf72b15b 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -177,6 +177,14 @@ define( * List of tags of the unit. This list is managed by the content_tagging module. */ tags: null, + /** + * True if the xblock is not visible to students only via links. + */ + hide_from_toc: null, + /** + * True iff this xblock should display a "Contains staff only content" message. + */ + hide_from_toc_message: null, }, initialize: function() { diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index a230d17797c7..7253938b31c2 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -42,6 +42,8 @@ describe('CourseOutlinePage', function() { user_partition_info: {}, highlights_enabled: true, highlights_enabled_for_messaging: false, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options, {child_info: {children: children}}); }; @@ -68,7 +70,9 @@ describe('CourseOutlinePage', function() { show_review_rules: true, user_partition_info: {}, highlights_enabled: true, - highlights_enabled_for_messaging: false + highlights_enabled_for_messaging: false, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options, {child_info: {children: children}}); }; @@ -93,7 +97,9 @@ describe('CourseOutlinePage', function() { group_access: {}, user_partition_info: {}, highlights: [], - highlights_enabled: true + highlights_enabled: true, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options, {child_info: {children: children}}); }; @@ -123,7 +129,9 @@ describe('CourseOutlinePage', function() { }, user_partitions: [], group_access: {}, - user_partition_info: {} + user_partition_info: {}, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options, {child_info: {children: children}}); }; @@ -141,7 +149,9 @@ describe('CourseOutlinePage', function() { edited_by: 'MockUser', user_partitions: [], group_access: {}, - user_partition_info: {} + user_partition_info: {}, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options); }; @@ -1214,7 +1224,9 @@ describe('CourseOutlinePage', function() { is_practice_exam: false, is_proctored_exam: false, default_time_limit_minutes: 150, - hide_after_due: true + hide_after_due: true, + hide_from_toc: false, + enable_hide_from_toc_ui: true, }, [ createMockVerticalJSON({ has_changes: true, @@ -1397,6 +1409,7 @@ describe('CourseOutlinePage', function() { default_time_limit_minutes: 150, hide_after_due: true, is_onboarding_exam: false, + hide_from_toc: null, } }); expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH'); @@ -2240,6 +2253,8 @@ describe('CourseOutlinePage', function() { is_practice_exam: false, is_proctored_exam: false, default_time_limit_minutes: null, + hide_from_toc: false, + enable_hide_from_toc_ui: true, }, [ createMockVerticalJSON({ has_changes: true, @@ -2521,6 +2536,7 @@ describe('CourseOutlinePage', function() { publish: 'republish', metadata: { visible_to_staff_only: null, + hide_from_toc: null } }); }) diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index f79e65d20779..f52340dea8fb 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -314,6 +314,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', var isProctoredExam = xblockInfo.get('is_proctored_exam'); var isPracticeExam = xblockInfo.get('is_practice_exam'); var isOnboardingExam = xblockInfo.get('is_onboarding_exam'); + var enableHideFromTOCUI = xblockInfo.get('enable_hide_from_toc_ui'); + var hideFromTOC = xblockInfo.get('hide_from_toc'); var html = this.template($.extend({}, { xblockInfo: xblockInfo, xblockType: this.options.xblockType, @@ -323,6 +325,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', isProctoredExam: isProctoredExam, isPracticeExam: isPracticeExam, isOnboardingExam: isOnboardingExam, + enableHideFromTOCUI: enableHideFromTOCUI, + hideFromTOC: hideFromTOC, isTimedExam: isTimeLimited && !( isProctoredExam || isPracticeExam || isOnboardingExam ), @@ -798,6 +802,10 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', return this.model.get('ancestor_has_staff_lock'); }, + isModelHiddenFromTOC: function() { + return this.model.get('hide_from_toc'); + }, + getContext: function() { return { hasExplicitStaffLock: this.isModelLocked(), @@ -812,6 +820,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', afterRender: function() { AbstractVisibilityEditor.prototype.afterRender.call(this); this.setLock(this.isModelLocked()); + this.setHideFromTOC(this.isModelHiddenFromTOC()); + this.setVisibleToLearners(this.isVisibleToLearners()); }, setLock: function(value) { @@ -822,8 +832,24 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', return this.$('#staff_lock').is(':checked'); }, + setHideFromTOC: function(value) { + this.$('#hide_from_toc').prop('checked', value); + }, + + setVisibleToLearners: function(value) { + this.$('#visible_to_learners').prop('checked', value); + }, + + isVisibleToLearners: function() { + return this.$('#staff_lock').is(':not(:checked)') && this.$('#hide_from_toc').is(':not(:checked)'); + }, + + isHiddenFromTOC: function() { + return this.$('#hide_from_toc').is(':checked'); + }, + hasChanges: function() { - return this.isModelLocked() !== this.isLocked(); + return this.isModelLocked() !== this.isLocked() || this.isModelHiddenFromTOC() !== this.isHiddenFromTOC(); }, getRequestData: function() { @@ -831,7 +857,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', return { publish: 'republish', metadata: { - visible_to_staff_only: this.isLocked() ? true : null + visible_to_staff_only: this.isLocked() || null, + hide_from_toc: this.isHiddenFromTOC() || null } }; } else { @@ -1055,12 +1082,20 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', if (this.currentVisibility() === 'staff_only') { metadata.visible_to_staff_only = true; metadata.hide_after_due = null; + metadata.hide_from_toc = null; } else if (this.currentVisibility() === 'hide_after_due') { metadata.visible_to_staff_only = null; metadata.hide_after_due = true; - } else { + metadata.hide_from_toc = null; + } else if (this.currentVisibility() === 'hide_from_toc'){ + metadata.visible_to_staff_only = null; + metadata.hide_after_due = null; + metadata.hide_from_toc = true; + } + else { metadata.visible_to_staff_only = null; metadata.hide_after_due = null; + metadata.hide_from_toc = null; } return { diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index 7ea09eff086e..2fe33df88eee 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -149,6 +149,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H releaseDate: this.model.get('release_date'), releaseDateFrom: this.model.get('release_date_from'), hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), + hideFromTOC: this.model.get('hide_from_toc'), staffLockFrom: this.model.get('staff_lock_from'), enableCopyUnit: this.model.get('enable_copy_paste_units'), course: window.course, diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index 9abe0866ed48..e955a0a3adc6 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -29,6 +29,8 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { * * staffOnly - all of the block's content is to be shown to staff only * Note: staff only items do not affect their parent's state. + * + * hideFromTOC - all of the block's content is to be hidden from the table of contents. */ VisibilityState = { live: 'live', @@ -36,7 +38,8 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { unscheduled: 'unscheduled', needsAttention: 'needs_attention', staffOnly: 'staff_only', - gated: 'gated' + gated: 'gated', + hideFromTOC: 'hide_from_toc' }; /** @@ -310,6 +313,9 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { if (visibilityState === VisibilityState.staffOnly) { return 'is-staff-only'; } + if (visibilityState === VisibilityState.hideFromTOC) { + return 'is-hidden-from-toc'; + } if (visibilityState === VisibilityState.gated) { return 'is-gated'; } diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index 4e826025b971..d5a12e6f0c91 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -112,6 +112,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE includesChildren: this.shouldRenderChildren(), hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), staffOnlyMessage: this.model.get('staff_only_message'), + hideFromTOCMessage: this.model.get('hide_from_toc_message'), course: course, enableCopyPasteUnits: this.model.get("enable_copy_paste_units"), // ENABLE_COPY_PASTE_UNITS waffle flag useTaggingTaxonomyListPage: this.model.get("use_tagging_taxonomy_list_page"), // ENABLE_TAGGING_TAXONOMY_LIST_PAGE waffle flag diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 4f7532c915e3..29a2989c1ae9 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -507,6 +507,18 @@ $outline-indent-width: $baseline; } } + // CASE: is hidden from TOC + &.is-hidden-from-toc { + // needed to make sure direct children only + > .section-status, + > .subsection-status, + > .unit-status { + .status-message .icon { + color: $color-hide-from-toc; + } + } + } + // CASE: has gated content &.is-gated { @@ -603,6 +615,11 @@ $outline-indent-width: $baseline; border-left-color: $color-staff-only; } + // CASE: is hidden from TOC + &.is-hidden-from-toc { + border-left-color: $color-hide-from-toc; + } + // CASE: has gated content &.is-gated { border-left-color: $color-gated; @@ -698,6 +715,11 @@ $outline-indent-width: $baseline; border-left-color: $color-staff-only; } + // CASE: is hidden from TOC + &.is-hidden-from-toc { + border-left-color: $color-hide-from-toc; + } + // CASE: is presented for gated &.is-gated { border-left-color: $color-gated; diff --git a/cms/static/sass/partials/cms/theme/_variables-v1.scss b/cms/static/sass/partials/cms/theme/_variables-v1.scss index 96777b34f27a..c2ff073b94b1 100644 --- a/cms/static/sass/partials/cms/theme/_variables-v1.scss +++ b/cms/static/sass/partials/cms/theme/_variables-v1.scss @@ -218,6 +218,7 @@ $color-ready: $green !default; $color-warning: $orange-l2 !default; $color-error: $red-l2 !default; $color-staff-only: $black !default; +$color-hide-from-toc: $black !default; $color-gated: $black !default; $color-heading-base: $gray-d2 !default; diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index f28ac839c569..5a674ba6e03a 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -155,6 +155,15 @@ } } + // CASE: is hidden from TOC + &.is-hidden-from-toc{ + @extend %bar-module-black; + + &.is-scheduled .wrapper-release .copy { + text-decoration: line-through; + } + } + // CASE: content is gated &.is-gated { @extend %bar-module-black; diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index fbcb2941b24f..b3eca807e076 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -90,7 +90,7 @@