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