Working publisher/subscriber events for Cohorts and discussions

This commit is contained in:
Albert St. Aubin
2017-05-30 11:35:38 -04:00
parent 3abc7dc07e
commit fd7ac21608
18 changed files with 187 additions and 58 deletions

View File

@@ -239,7 +239,7 @@ FEATURES = {
'ALLOW_PUBLIC_ACCOUNT_CREATION': True,
# Whether or not the dynamic EnrollmentTrackUserPartition should be registered.
'ENABLE_ENROLLMENT_TRACK_USER_PARTITION': False,
'ENABLE_ENROLLMENT_TRACK_USER_PARTITION': True,
}
ENABLE_JASMINE = False

View File

@@ -56,6 +56,12 @@ class InstructorDashboardPage(CoursePage):
discussion_management_section.wait_for_page()
return discussion_management_section
def is_discussion_management_visible(self):
"""
Is the Discussion tab visible
"""
return self.q(css='[data-section="discussions_management"').visible
def select_data_download(self):
"""
Selects the data download tab and returns a DataDownloadPage.

View File

@@ -310,6 +310,7 @@ class DivisionSchemeTest(BaseDividedDiscussionTest, BaseDiscussionMixin):
Go to the discussion tab on the instructor dashboard.
"""
self.instructor_dashboard_page.visit()
self.assertTrue(self.instructor_dashboard_page.is_discussion_management_visible())
self.instructor_dashboard_page.select_discussion_management()
self.discussion_management_page.wait_for_page()
@@ -357,11 +358,55 @@ class DivisionSchemeTest(BaseDividedDiscussionTest, BaseDiscussionMixin):
def test_disabling_cohorts(self):
"""
Test that disabling cohorts hides the cohort division scheme iff it is not the selected scheme
Test that the discussions management tab hides when there is <= 1 enrollment track, the Cohort division scheme
is not selected, and cohorts are disabled.
(even without reloading the page).
"""
# TODO: will be added as part of AJ's work.
pass
self.disable_cohorting(self.course_fixture)
self.instructor_dashboard_page.visit()
self.assertFalse(self.instructor_dashboard_page.is_discussion_management_visible())
def test_disabling_cohorts_while_selected(self):
"""
Test that disabling cohorts does not hide the discussion tab when there is more than one enrollment track.
Also that the division scheme for cohorts is visible iff it was selected.
(even without reloading the page).
"""
add_enrollment_course_modes(self.browser, self.course_id, ['audit', 'verified'])
# Verify that the tab is visible, the cohort scheme is selected by default for divided discussions
self.disable_cohorting(self.course_fixture)
# Go to Discussions tab and ensure that the correct scheme options are visible
self.view_discussion_management_page()
self.assertTrue(
self.discussion_management_page.division_scheme_visible(
self.discussion_management_page.COHORT_SCHEME
)
)
def test_disabling_cohorts_while_not_selected(self):
"""
Test that disabling cohorts does not hide the discussion tab when there is more than one enrollment track.
Also that the division scheme for cohorts is not visible when cohorts are disabled and another scheme is
selected for division.
(even without reloading the page).
"""
add_enrollment_course_modes(self.browser, self.course_id, ['audit', 'verified'])
# Verify that the tab is visible
self.view_discussion_management_page()
self.discussion_management_page.select_division_scheme(self.discussion_management_page.ENROLLMENT_TRACK_SCHEME)
self.verify_save_confirmation_message(self.scheme_key)
self.disable_cohorting(self.course_fixture)
# Go to Discussions tab and ensure that the correct scheme options are visible
self.view_discussion_management_page()
self.assertFalse(
self.discussion_management_page.division_scheme_visible(
self.discussion_management_page.COHORT_SCHEME
)
)
def test_single_enrollment_mode(self):
"""

View File

@@ -868,11 +868,22 @@ def available_division_schemes(course_key):
available_schemes = []
if is_course_cohorted(course_key):
available_schemes.append(CourseDiscussionSettings.COHORT)
if len(_get_enrollment_track_groups(course_key)) > 1:
if enrollment_track_group_count(course_key) > 1:
available_schemes.append(CourseDiscussionSettings.ENROLLMENT_TRACK)
return available_schemes
def enrollment_track_group_count(course_key):
"""
Returns the count of possible enrollment track division schemes for this course.
Args:
course_key: CourseKey
Returns:
Count of enrollment track division scheme
"""
return len(_get_enrollment_track_groups(course_key))
def _get_course_division_scheme(course_discussion_settings):
division_scheme = course_discussion_settings.division_scheme
if (
@@ -882,7 +893,7 @@ def _get_course_division_scheme(course_discussion_settings):
division_scheme = CourseDiscussionSettings.NONE
elif (
division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK and
len(_get_enrollment_track_groups(course_discussion_settings.course_id)) <= 1
enrollment_track_group_count(course_discussion_settings.course_id) <= 1
):
division_scheme = CourseDiscussionSettings.NONE
return division_scheme

View File

@@ -30,7 +30,7 @@ class TestECommerceDashboardViews(SiteMixin, SharedModuleStoreTestCase):
# URL for instructor dash
cls.url = reverse('instructor_dashboard', kwargs={'course_id': cls.course.id.to_deprecated_string()})
cls.ecommerce_link = '<button type="button" class="btn-link" data-section="e-commerce">E-Commerce</button>'
cls.ecommerce_link = '<button type="button" class="btn-link e-commerce" data-section="e-commerce">E-Commerce</button>'
def setUp(self):
super(TestECommerceDashboardViews, self).setUp()

View File

@@ -31,7 +31,7 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase):
# URL for instructor dash
cls.url = reverse('instructor_dashboard', kwargs={'course_id': cls.course.id.to_deprecated_string()})
# URL for email view
cls.email_link = '<button type="button" class="btn-link" data-section="send_email">Email</button>'
cls.email_link = '<button type="button" class="btn-link send_email" data-section="send_email">Email</button>'
def setUp(self):
super(TestNewInstructorDashboardEmailViewMongoBacked, self).setUp()
@@ -126,7 +126,7 @@ class TestNewInstructorDashboardEmailViewXMLBacked(SharedModuleStoreTestCase):
# URL for instructor dash
cls.url = reverse('instructor_dashboard', kwargs={'course_id': cls.course_key.to_deprecated_string()})
# URL for email view
cls.email_link = '<button type="button" class="btn-link" data-section="send_email">Email</button>'
cls.email_link = '<button type="button" class="btn-link send_email" data-section="send_email">Email</button>'
def setUp(self):
super(TestNewInstructorDashboardEmailViewXMLBacked, self).setUp()
@@ -138,7 +138,7 @@ class TestNewInstructorDashboardEmailViewXMLBacked(SharedModuleStoreTestCase):
# URL for instructor dash
self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course_key.to_deprecated_string()})
# URL for email view
self.email_link = '<button type="button" class="btn-link" data-section="send_email">Email</button>'
self.email_link = '<button type="button" class="btn-link send_email" data-section="send_email">Email</button>'
def tearDown(self):
super(TestNewInstructorDashboardEmailViewXMLBacked, self).tearDown()

View File

@@ -27,7 +27,7 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
# URL for instructor dash
cls.url = reverse('instructor_dashboard', kwargs={'course_id': cls.course.id.to_deprecated_string()})
button = '<button type="button" class="btn-link" data-section="special_exams">Special Exams</button>'
button = '<button type="button" class="btn-link special_exams" data-section="special_exams">Special Exams</button>'
cls.proctoring_link = button
def setUp(self):

View File

@@ -228,7 +228,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
Test analytics dashboard message is shown
"""
response = self.client.get(self.url)
analytics_section = '<li class="nav-item"><button type="button" class="btn-link" data-section="instructor_analytics">Analytics</button></li>' # pylint: disable=line-too-long
analytics_section = '<li class="nav-item"><button type="button" class="btn-link instructor_analytics" data-section="instructor_analytics">Analytics</button></li>' # pylint: disable=line-too-long
self.assertIn(analytics_section, response.content)
# link to dashboard shown
@@ -327,7 +327,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
"""
ora_section = (
'<li class="nav-item">'
'<button type="button" class="btn-link" data-section="open_response_assessment">'
'<button type="button" class="btn-link open_response_assessment" data-section="open_response_assessment">'
'Open Responses'
'</button>'
'</li>'

View File

@@ -31,8 +31,8 @@ from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from courseware.access import has_access
from courseware.courses import get_course_by_id, get_studio_url
from django_comment_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from django_comment_client.utils import has_forum_access, available_division_schemes, enrollment_track_group_count
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, CourseDiscussionSettings
from openedx.core.djangoapps.course_groups.cohorts import get_course_cohorts, is_course_cohorted, DEFAULT_COHORT_NAME
from student.models import CourseEnrollment
from shoppingcart.models import Coupon, PaidCourseRegistration, CourseRegCodeItem
@@ -524,9 +524,13 @@ def _section_cohort_management(course, access):
def _section_discussions_management(course, access):
""" Provide data for the corresponding discussion management section """
course_key = course.id
enrollment_track_schemes = available_division_schemes(course_key)
section_data = {
'section_key': 'discussions_management',
'section_display_name': _('Discussions'),
'is_hidden': (not is_course_cohorted(course_key) and
CourseDiscussionSettings.ENROLLMENT_TRACK not in enrollment_track_schemes),
'enrollment_track_count': enrollment_track_group_count(course_key),
'discussion_topics_url': reverse('discussion_topics', kwargs={'course_key_string': unicode(course_key)}),
'course_discussion_settings': reverse(
'course_discussions_settings',

View File

@@ -379,7 +379,7 @@ FEATURES = {
'ENABLE_COOKIE_CONSENT': False,
# Whether or not the dynamic EnrollmentTrackUserPartition should be registered.
'ENABLE_ENROLLMENT_TRACK_USER_PARTITION': False,
'ENABLE_ENROLLMENT_TRACK_USER_PARTITION': True,
# Enable one click program purchase
# See LEARNER-493

View File

@@ -5,27 +5,34 @@
'js/discussions_management/views/divided_discussions_course_wide',
'edx-ui-toolkit/js/utils/html-utils',
'edx-ui-toolkit/js/utils/string-utils',
'js/views/base_dashboard_view',
'js/models/notification',
'js/views/notification'
],
function($, _, Backbone, gettext, InlineDiscussionsView, CourseWideDiscussionsView, HtmlUtils, StringUtils) {
function($, _, Backbone, gettext, InlineDiscussionsView,
CourseWideDiscussionsView,
HtmlUtils, StringUtils, BaseDashboardView) {
/* global NotificationModel, NotificationView */
var hiddenClass = 'is-hidden';
var cohort = 'cohort';
var none = 'none';
var enrollmentTrack = 'enrollment_track';
var HIDDEN_CLASS = 'hidden';
var TWO_COLUMN_CLASS = 'two-column-layout';
var THREE_COLUMN_CLASS = 'three-column-layout';
var COHORT = 'cohort';
var NONE = 'none';
var ENROLLMENT_TRACK = 'enrollment_track';
var DiscussionsView = Backbone.View.extend({
var DiscussionsView = BaseDashboardView.extend({
events: {
'click .division-scheme': 'divisionSchemeChanged'
'click .division-scheme': 'divisionSchemeChanged',
'change .cohorts-state': 'onCohortsEnabledChanged'
},
initialize: function(options) {
this.template = HtmlUtils.template($('#discussions-tpl').text());
this.context = options.context;
this.discussionSettings = options.discussionSettings;
this.listenTo(this.pubSub, 'cohorts:state', this.cohortStateUpdate, this);
},
render: function() {
@@ -42,25 +49,25 @@
getDivisionSchemeData: function(selectedScheme) {
return [
{
key: none,
key: NONE,
displayName: gettext('Not divided'),
descriptiveText: gettext('Discussions are unified; all learners interact with posts from other learners, regardless of the group they are in.'), // eslint-disable-line max-len
selected: selectedScheme === none,
selected: selectedScheme === NONE,
enabled: true // always leave none enabled
},
{
key: enrollmentTrack,
key: ENROLLMENT_TRACK,
displayName: gettext('Enrollment Tracks'),
descriptiveText: gettext('Use enrollment tracks as the basis for dividing discussions. All learners, regardless of their enrollment track, see the same discussion topics, but within divided topics, only learners who are in the same enrollment track see and respond to each others posts.'), // eslint-disable-line max-len
selected: selectedScheme === enrollmentTrack,
enabled: this.isSchemeAvailable(enrollmentTrack) || selectedScheme === enrollmentTrack
selected: selectedScheme === ENROLLMENT_TRACK,
enabled: this.isSchemeAvailable(ENROLLMENT_TRACK) || selectedScheme === ENROLLMENT_TRACK
},
{
key: cohort,
key: COHORT,
displayName: gettext('Cohorts'),
descriptiveText: gettext('Use cohorts as the basis for dividing discussions. All learners, regardless of cohort, see the same discussion topics, but within divided topics, only members of the same cohort see and respond to each others posts. '), // eslint-disable-line max-len
selected: selectedScheme === cohort,
enabled: this.isSchemeAvailable(cohort) || selectedScheme === cohort
selected: selectedScheme === COHORT,
enabled: this.isSchemeAvailable(COHORT) || selectedScheme === COHORT
}
];
@@ -80,6 +87,42 @@
this.notification.render();
},
cohortStateUpdate: function(state) {
if ($('.discussions-management').data('enrollment-track-count') <= 1) {
this.showDiscussionManagement(state.is_cohorted);
} if (this.getSelectedScheme() !== COHORT) {
this.showCohortSchemeControl(state.is_cohorted);
}
},
showDiscussionManagement: function(show) {
if (!show) {
$('.btn-link.discussions_management').addClass(HIDDEN_CLASS);
$('#discussions_management').addClass(HIDDEN_CLASS);
} else {
$('.btn-link.discussions_management').removeClass(HIDDEN_CLASS);
$('#discussions_management').removeClass(HIDDEN_CLASS);
}
},
showCohortSchemeControl: function(show) {
if (!show) {
$('.division-scheme-item.cohort').addClass(HIDDEN_CLASS);
this.updateSchemeSelectionLayout(2);
} else {
$('.division-scheme-item.cohort').removeClass(HIDDEN_CLASS);
this.updateSchemeSelectionLayout(3);
}
},
updateSchemeSelectionLayout: function(columns) {
if (columns === 2) {
$('.division-scheme-item').removeClass(THREE_COLUMN_CLASS).addClass(TWO_COLUMN_CLASS);
} else {
$('.division-scheme-item').removeClass(TWO_COLUMN_CLASS).addClass(THREE_COLUMN_CLASS);
}
},
removeNotification: function() {
if (this.notification) {
this.notification.remove();
@@ -120,13 +163,13 @@
fieldData, {patch: true, wait: true}
).done(function() {
switch (selectedScheme) {
case none:
case NONE:
details = gettext('Discussion topics in the course are not divided.');
break;
case enrollmentTrack:
case ENROLLMENT_TRACK:
details = gettext('Any divided discussion topics are divided based on enrollment track.'); // eslint-disable-line max-len
break;
case cohort:
case COHORT:
details = gettext('Any divided discussion topics are divided based on cohort.');
break;
default:
@@ -156,10 +199,10 @@
},
updateTopicVisibility: function(selectedScheme, topicNav) {
if (selectedScheme === none) {
topicNav.addClass(hiddenClass);
if (selectedScheme === NONE) {
topicNav.addClass(HIDDEN_CLASS);
} else {
topicNav.removeClass(hiddenClass);
topicNav.removeClass(HIDDEN_CLASS);
}
},

View File

@@ -1,20 +1,24 @@
(function(define) {
'use strict';
define(['jquery', 'underscore', 'backbone', 'gettext', 'js/groups/models/cohort',
'js/groups/models/verified_track_settings',
'js/groups/views/cohort_editor', 'js/groups/views/cohort_form',
'js/groups/views/course_cohort_settings_notification',
'js/groups/views/verified_track_settings_notification',
'edx-ui-toolkit/js/utils/html-utils',
'js/views/file_uploader', 'js/models/notification', 'js/views/notification', 'string_utils'],
function($, _, Backbone, gettext, CohortModel, VerifiedTrackSettingsModel, CohortEditorView, CohortFormView,
CourseCohortSettingsNotificationView, VerifiedTrackSettingsNotificationView, HtmlUtils) {
var hiddenClass = 'is-hidden',
'js/groups/models/verified_track_settings',
'js/groups/views/cohort_editor', 'js/groups/views/cohort_form',
'js/groups/views/course_cohort_settings_notification',
'js/groups/views/verified_track_settings_notification',
'edx-ui-toolkit/js/utils/html-utils',
'js/views/base_dashboard_view',
'js/views/file_uploader', 'js/models/notification', 'js/views/notification',
'string_utils'],
function($, _, Backbone, gettext, CohortModel,
VerifiedTrackSettingsModel,
CohortEditorView, CohortFormView,
CourseCohortSettingsNotificationView,
VerifiedTrackSettingsNotificationView, HtmlUtils, BaseDashboardView) {
var hiddenClass = 'hidden',
disabledClass = 'is-disabled',
enableCohortsSelector = '.cohorts-state';
var CohortsView = Backbone.View.extend({
var CohortsView = BaseDashboardView.extend({
events: {
'change .cohort-select': 'onCohortSelected',
'change .cohorts-state': 'onCohortsEnabledChanged',
@@ -27,7 +31,6 @@
initialize: function(options) {
var model = this.model;
this.template = HtmlUtils.template($('#cohorts-tpl').text());
this.selectorTemplate = HtmlUtils.template($('#cohort-selector-tpl').text());
this.context = options.context;
@@ -151,6 +154,7 @@
).done(function() {
self.render();
self.renderCourseCohortSettingsNotificationView();
self.pubSub.trigger('cohorts:state', fieldData);
}).fail(function(result) {
self.showNotification({
type: 'error',

View File

@@ -276,7 +276,7 @@ define(['backbone', 'jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers
);
// If no cohorts have been created, can't upload a CSV file.
expect(cohortsView.$('.wrapper-cohort-supplemental')).toHaveClass('is-hidden');
expect(cohortsView.$('.wrapper-cohort-supplemental')).toHaveClass('hidden');
});
it('syncs data when membership tab is clicked', function() {
@@ -294,7 +294,7 @@ define(['backbone', 'jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers
createCohortsView(this);
// Should see the control to toggle CSV file upload.
expect(cohortsView.$('.wrapper-cohort-supplemental')).not.toHaveClass('is-hidden');
expect(cohortsView.$('.wrapper-cohort-supplemental')).not.toHaveClass('hidden');
// But upload form should not be visible until toggle is clicked.
expect(cohortsView.$(fileUploadFormCss).length).toBe(0);
uploadCsvToggle = cohortsView.$('.toggle-cohort-management-secondary');
@@ -302,7 +302,7 @@ define(['backbone', 'jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers
toContain('Assign students to cohorts by uploading a CSV file');
uploadCsvToggle.click();
// After toggle is clicked, it should be hidden.
expect(uploadCsvToggle).toHaveClass('is-hidden');
expect(uploadCsvToggle).toHaveClass('hidden');
fileUploadForm = cohortsView.$(fileUploadFormCss);
expect(fileUploadForm.length).toBe(1);
@@ -517,7 +517,7 @@ define(['backbone', 'jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers
cohortsView.$('.action-create').click();
expect(cohortsView.$('.cohort-management-settings-form').length).toBe(1);
expect(cohortsView.$('.cohort-management-nav')).toHaveClass('is-disabled');
expect(cohortsView.$('.cohort-management-group')).toHaveClass('is-hidden');
expect(cohortsView.$('.cohort-management-group')).toHaveClass('hidden');
cohortsView.$('.cohort-name').val(defaultCohortName);
cohortsView.$('.type-random').prop('checked', true).change();
selectContentGroup(contentGroupId, MOCK_COHORTED_USER_PARTITION_ID);
@@ -544,7 +544,7 @@ define(['backbone', 'jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers
);
verifyHeader(1, defaultCohortName, 0, MOCK_RANDOM_ASSIGNMENT);
expect(cohortsView.$('.cohort-management-nav')).not.toHaveClass('is-disabled');
expect(cohortsView.$('.cohort-management-group')).not.toHaveClass('is-hidden');
expect(cohortsView.$('.cohort-management-group')).not.toHaveClass('hidden');
expect(getAddModal().find('.cohort-management-settings-form').length).toBe(0);
});

View File

@@ -0,0 +1,13 @@
(function(define) {
'use strict';
define(['jquery', 'backbone'],
function($, Backbone) {
// This Base view is useful when eventing or other features are shared between two or more
// views. Included with this view in the pubSub object allowing for events to be triggered
// and shared with other views.
var BaseDashboardView = Backbone.View.extend({
pubSub: $.extend({}, Backbone.Events)
});
return BaseDashboardView;
});
}).call(this, define || RequireJS.define);

View File

@@ -35,7 +35,7 @@
<!-- Uploading a CSV file of cohort assignments. -->
<button class="toggle-cohort-management-secondary" data-href="#cohort-management-file-upload"><%- gettext('Assign students to cohorts by uploading a CSV file') %></button>
<div class="cohort-management-file-upload csv-upload is-hidden" id="cohort-management-file-upload" tabindex="-1"></div>
<div class="cohort-management-file-upload csv-upload hidden" id="cohort-management-file-upload" tabindex="-1"></div>
<div class="cohort-management-supplemental">
<p class="">

View File

@@ -4,7 +4,7 @@
<div class="division-scheme-container">
<div class="division-scheme-items" role="group" aria-labelledby="division-scheme-title">
<% for (var i = 0; i < availableSchemes.length; i++) { %>
<div class="division-scheme-item <%- layoutClass %> <% if (!availableSchemes[i].enabled) { %>is-hidden<% } %>">
<div class="division-scheme-item <%- availableSchemes[i].key %> <%- layoutClass %> <% if (!availableSchemes[i].enabled) { %>hidden<% } %>">
<label class="division-scheme-label">
<input class="division-scheme <%- availableSchemes[i].key %>" type="radio" name="division-scheme"
value="<%- availableSchemes[i].key %>" aria-describedby="<%- availableSchemes[i].key %>-description"

View File

@@ -12,6 +12,7 @@ from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_
<div class="discussions-management"
data-discussion-topics-url="${section_data['discussion_topics_url']}"
data-course-discussion-settings-url="${section_data['course_discussion_settings']}"
data-enrollment-track-count="${section_data['enrollment_track_count']}"
>
</div>

View File

@@ -121,9 +121,10 @@ from openedx.core.djangolib.markup import HTML
## when the javascript loads, it clicks on the first section
<ul class="instructor-nav">
% for section_data in sections:
<% is_hidden = section_data.get('is_hidden', False) %>
## This is necessary so we don't scrape 'section_display_name' as a string.
<% dname = section_data['section_display_name'] %>
<li class="nav-item"><button type="button" class="btn-link" data-section="${ section_data['section_key'] }">${_(dname)}</button></li>
<li class="nav-item"><button type="button" class="btn-link ${ section_data['section_key'] }${' hidden' if is_hidden else ''}" data-section="${ section_data['section_key'] }">${_(dname)}</button></li>
% endfor
</ul>
@@ -131,7 +132,8 @@ from openedx.core.djangolib.markup import HTML
## to keep this short, sections can be pulled out into their own files
% for section_data in sections:
<section id="${ section_data['section_key'] }" class="idash-section" aria-labelledby="header-${section_data['section_key']}">
<% is_hidden = section_data.get('is_hidden', False) %>
<section id="${ section_data['section_key'] }" class="idash-section${' hidden' if hidden else ''}" aria-labelledby="header-${section_data['section_key']}">
<h3 class="hd hd-3" id="header-${ section_data['section_key'] }">${ section_data['section_display_name'] }</h3>
<%include file="${ section_data['section_key'] }.html" args="section_data=section_data" />
</section>