From 56ac149962bbf1d0931f871f8c82e5dfedcab2b9 Mon Sep 17 00:00:00 2001 From: Giulio Gratta <caesar2164@gmail.com> Date: Fri, 26 Jan 2018 12:04:12 -0800 Subject: [PATCH] Add custom HTML to Course About page sidebar This allows course staff to add arbitrary HTML to the sidebar on the About page. A waffle switch has been added in the course_experience app to allow enabling and disabling of this feature. --- .../tests/test_course_settings.py | 6 +++ cms/djangoapps/contentstore/views/course.py | 4 ++ cms/static/js/views/settings/main.js | 8 ++- cms/static/sass/views/_settings.scss | 6 ++- cms/templates/settings.html | 12 +++++ lms/djangoapps/courseware/courses.py | 2 + lms/djangoapps/courseware/tests/test_about.py | 49 ++++++++++++++++++- lms/djangoapps/courseware/views/views.py | 5 ++ .../sass/multicourse/_course_about.scss | 6 +++ lms/templates/courseware/course_about.html | 7 +++ .../core/djangoapps/models/course_details.py | 2 + .../models/tests/test_course_details.py | 5 ++ openedx/features/course_experience/waffle.py | 17 +++++++ 13 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 openedx/features/course_experience/waffle.py diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 9ab5f1164db..60defed4ec6 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -130,6 +130,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012, 11, 15, 1, 30, tzinfo=UTC)) self.alter_field(url, details, 'short_description', "Short Description") + self.alter_field(url, details, 'about_sidebar_html', "About Sidebar HTML") self.alter_field(url, details, 'overview', "Overview") self.alter_field(url, details, 'intro_video', "intro_video") self.alter_field(url, details, 'effort', "effort") @@ -148,6 +149,9 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertEqual( details['short_description'], encoded['short_description'], context + " short_description not ==" ) + self.assertEqual( + details['about_sidebar_html'], encoded['about_sidebar_html'], context + " about_sidebar_html not ==" + ) self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==") self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") @@ -270,6 +274,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertContains(response, "Introducing Your Course") self.assertContains(response, "Course Card Image") self.assertContains(response, "Course Short Description") + self.assertNotContains(response, "Course About Sidebar HTML") self.assertNotContains(response, "Course Title") self.assertNotContains(response, "Course Subtitle") self.assertNotContains(response, "Course Duration") @@ -425,6 +430,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertContains(response, "Course Duration") self.assertContains(response, "Course Description") self.assertContains(response, "Course Short Description") + self.assertNotContains(response, "Course About Sidebar HTML") self.assertContains(response, "Course Overview") self.assertContains(response, "Course Introduction Video") self.assertContains(response, "Requirements") diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 354efd8fb9b..d88375b65a6 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -25,6 +25,8 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import BlockUsageLocator from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace +from openedx.features.course_experience.waffle import waffle as course_experience_waffle +from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML from six import text_type from contentstore.course_group_config import ( @@ -1050,6 +1052,7 @@ def settings_handler(request, course_key_string): 'EDITABLE_SHORT_DESCRIPTION', settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True) ) + sidebar_html_enabled = course_experience_waffle().is_enabled(ENABLE_COURSE_ABOUT_SIDEBAR_HTML) # self_paced_enabled = SelfPacedConfiguration.current().enabled settings_context = { @@ -1062,6 +1065,7 @@ def settings_handler(request, course_key_string): 'details_url': reverse_course_url('settings_handler', course_key), 'about_page_editable': about_page_editable, 'short_description_editable': short_description_editable, + 'sidebar_html_enabled': sidebar_html_enabled, 'upload_asset_url': upload_asset_url, 'course_handler_url': reverse_course_url('course_handler', course_key), 'language_options': settings.ALL_LANGUAGES, diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index 966e3a035cc..358d6cbb087 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -16,6 +16,7 @@ define(['js/views/validation', 'codemirror', 'underscore', 'jquery', 'jquery.ui' 'change select': 'updateModel', 'click .remove-course-introduction-video': 'removeVideo', 'focus #course-overview': 'codeMirrorize', + 'focus #course-about-sidebar-html': 'codeMirrorize', 'mouseover .timezone': 'updateTime', // would love to move to a general superclass, but event hashes don't inherit in backbone :-( 'focus :input': 'inputFocus', @@ -97,6 +98,10 @@ define(['js/views/validation', 'codemirror', 'underscore', 'jquery', 'jquery.ui' this.$el.find('#' + this.fieldToSelectorMap.description).val(this.model.get('description')); this.$el.find('#' + this.fieldToSelectorMap.short_description).val(this.model.get('short_description')); + this.$el.find('#' + this.fieldToSelectorMap.about_sidebar_html).val( + this.model.get('about_sidebar_html') + ); + this.codeMirrorize(null, $('#course-about-sidebar-html')[0]); this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample()); this.$el.find('#' + this.fieldToSelectorMap.intro_video).val(this.model.get('intro_video') || ''); @@ -163,6 +168,7 @@ define(['js/views/validation', 'codemirror', 'underscore', 'jquery', 'jquery.ui' subtitle: 'course-subtitle', duration: 'course-duration', description: 'course-description', + about_sidebar_html: 'course-about-sidebar-html', short_description: 'course-short-description', intro_video: 'course-introduction-video', effort: 'course-effort', @@ -363,7 +369,7 @@ define(['js/views/validation', 'codemirror', 'underscore', 'jquery', 'jquery.ui' } }); cmTextArea = this.codeMirrors[thisTarget.id].getInputField(); - cmTextArea.setAttribute('id', 'course-overview-cm-textarea'); + cmTextArea.setAttribute('id', thisTarget.id + '-cm-textarea'); } }, diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index 3a93ac2146c..759e68c47a3 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -507,8 +507,10 @@ } // specific fields - overview - #field-course-overview { - #course-overview { + #field-course-overview, + #field-course-about-sidebar-html { + #course-overview, + #course-about-sidebar-html { height: ($baseline*20); } diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 57db1ceb5a7..4d8445b52a3 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -366,6 +366,18 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url | n, js_escaped_string}' a_link_end=HTML("</a>") )}</span> </li> + % if sidebar_html_enabled: + <li class="field text" id="field-course-about-sidebar-html"> + <label for="course-about-sidebar-html">${_("Course About Sidebar HTML")} + <textarea class="tinymce text-editor" id="course-about-sidebar-html" aria-describedby="tip-course-about-sidebar-html"></textarea> + </label> + <span class="tip tip-stacked" id="tip-course-about-sidebar-html">${ + Text(_("Custom sidebar content for {a_link_start}your course summary page{a_link_end} (formatted in HTML)")).format( + a_link_start=HTML("<a class='link-courseURL' rel='external' href='{lms_link_for_about_page}'>").format(lms_link_for_about_page=lms_link_for_about_page), + a_link_end=HTML("</a>") + )}</span> + </li> + % endif % endif <li class="field image" id="field-course-image"> diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index e1e9f9e3a99..cef3e7dd71e 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -224,6 +224,7 @@ def get_course_about_section(request, course, section_key): Valid keys: - overview + - about_sidebar_html - short_description - description - key_dates (includes start, end, exams, etc) @@ -259,6 +260,7 @@ def get_course_about_section(request, course, section_key): 'effort', 'end_date', 'prerequisites', + 'about_sidebar_html', 'ocw_links' } diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index d3c2795690c..9aa1b70f92b 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -2,7 +2,7 @@ Test the about xblock """ import datetime - +import ddt import pytz from ccx_keys.locator import CCXLocator from django.conf import settings @@ -12,9 +12,12 @@ from milestones.tests.utils import MilestonesTestCaseMixin from mock import patch from nose.plugins.attrib import attr from six import text_type +from waffle.testutils import override_switch from course_modes.models import CourseMode from lms.djangoapps.ccx.tests.factories import CcxFactory +from openedx.features.course_experience.waffle import WAFFLE_NAMESPACE as COURSE_EXPERIENCE_WAFFLE_NAMESPACE +from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML from shoppingcart.models import Order, PaidCourseRegistration from student.models import CourseEnrollment from student.tests.factories import AdminFactory, CourseEnrollmentAllowedFactory, UserFactory @@ -417,6 +420,50 @@ class AboutWithClosedEnrollment(ModuleStoreTestCase): self.assertNotIn('<span class="important-dates-item-text">$10</span>', resp.content) +@attr(shard=1) +@ddt.ddt +class AboutSidebarHTMLTestCase(SharedModuleStoreTestCase): + """ + This test case will check the About page for the content in the HTML sidebar. + """ + def setUp(self): + super(AboutSidebarHTMLTestCase, self).setUp() + self.course = CourseFactory.create() + + @ddt.data( + ("", "", False), + ("about_sidebar_html", "About Sidebar HTML Heading", False), + ("about_sidebar_html", "", False), + ("", "", True), + ("about_sidebar_html", "About Sidebar HTML Heading", True), + ("about_sidebar_html", "", True), + ) + @ddt.unpack + def test_html_sidebar_enabled(self, itemfactory_display_name, itemfactory_data, waffle_switch_value): + with override_switch( + '{}.{}'.format( + COURSE_EXPERIENCE_WAFFLE_NAMESPACE, + ENABLE_COURSE_ABOUT_SIDEBAR_HTML + ), + active=waffle_switch_value + ): + if itemfactory_display_name: + ItemFactory.create( + category="about", + parent_location=self.course.location, + display_name=itemfactory_display_name, + data=itemfactory_data, + ) + url = reverse('about_course', args=[text_type(self.course.id)]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + if waffle_switch_value and itemfactory_display_name and itemfactory_data: + self.assertIn('<section class="about-sidebar-html">', resp.content) + self.assertIn(itemfactory_data, resp.content) + else: + self.assertNotIn('<section class="about-sidebar-html">', resp.content) + + @attr(shard=1) @patch.dict(settings.FEATURES, {'ENABLE_SHOPPING_CART': True}) @patch.dict(settings.FEATURES, {'ENABLE_PAID_COURSE_REGISTRATION': True}) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index d81b3f10b32..31e2a635cf6 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -90,6 +90,8 @@ from openedx.core.djangolib.markup import HTML, Text from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, course_home_url_name from openedx.features.course_experience.course_tools import CourseToolsPluginManager from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView +from openedx.features.course_experience.waffle import waffle as course_experience_waffle +from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML from openedx.features.enterprise_support.api import data_sharing_consent_required from shoppingcart.utils import is_shopping_cart_enabled from student.models import CourseEnrollment, UserTestGroup @@ -835,6 +837,8 @@ def course_about(request, course_id): # Overview overview = CourseOverview.get_from_id(course.id) + sidebar_html_enabled = course_experience_waffle().is_enabled(ENABLE_COURSE_ABOUT_SIDEBAR_HTML) + # This local import is due to the circularity of lms and openedx references. # This may be resolved by using stevedore to allow web fragments to be used # as plugins, and to avoid the direct import. @@ -872,6 +876,7 @@ def course_about(request, course_id): 'pre_requisite_courses': pre_requisite_courses, 'course_image_urls': overview.image_urls, 'reviews_fragment_view': reviews_fragment_view, + 'sidebar_html_enabled': sidebar_html_enabled, } return render_to_response('courseware/course_about.html', context) diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index 192a5279402..db8585966d5 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -439,6 +439,12 @@ background: url('#{$static-path}/images/link-icon.png') left center no-repeat; } } + + &.about-sidebar-html { + padding: 0 10px; + box-shadow: none; + border: none; + } } header { diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 1b88d66d045..d9b495df892 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -328,6 +328,13 @@ from six import string_types </div> %endif + % if sidebar_html_enabled: + % if get_course_about_section(request, course, "about_sidebar_html"): + <section class="about-sidebar-html"> + ${get_course_about_section(request, course, "about_sidebar_html")} + </section> + % endif + %endif </div> </div> diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index a06a0183950..7f3aa764651 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -27,6 +27,7 @@ ABOUT_ATTRIBUTES = [ 'entrance_exam_enabled', 'entrance_exam_id', 'entrance_exam_minimum_score_pct', + 'about_sidebar_html', ] @@ -52,6 +53,7 @@ class CourseDetails(object): self.description = "" self.short_description = "" self.overview = "" # html to render as the overview + self.about_sidebar_html = "" self.intro_video = None # a video pointer self.effort = None # hours/week self.license = "all-rights-reserved" # default course license is all rights reserved diff --git a/openedx/core/djangoapps/models/tests/test_course_details.py b/openedx/core/djangoapps/models/tests/test_course_details.py index cd9cf17aca0..0d51d2a4753 100644 --- a/openedx/core/djangoapps/models/tests/test_course_details.py +++ b/openedx/core/djangoapps/models/tests/test_course_details.py @@ -72,6 +72,11 @@ class CourseDetailsTestCase(ModuleStoreTestCase): CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).intro_video, jsondetails.intro_video, "After set intro_video" ) + jsondetails.about_sidebar_html = "About Sidebar HTML" + self.assertEqual( + CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).about_sidebar_html, + jsondetails.about_sidebar_html, "After set about_sidebar_html" + ) jsondetails.effort = "effort" self.assertEqual( CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).effort, diff --git a/openedx/features/course_experience/waffle.py b/openedx/features/course_experience/waffle.py new file mode 100644 index 00000000000..8c96d36e077 --- /dev/null +++ b/openedx/features/course_experience/waffle.py @@ -0,0 +1,17 @@ +""" +Miscellaneous waffle switches that both LMS and Studio need to access +""" +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace + +# Namespace +WAFFLE_NAMESPACE = u'course_experience' + +# Switches +ENABLE_COURSE_ABOUT_SIDEBAR_HTML = u'enable_about_sidebar_html' + + +def waffle(): + """ + Returns the namespaced, cached, audited shared Waffle Switch class. + """ + return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'Course Experience: ') -- GitLab