diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 87d941632e..6f81d9de77 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -18,6 +18,7 @@ class CourseMetadata(object): # Should not be used directly. Instead the filtered_list method should # be used if the field needs to be filtered depending on the feature flag. FILTERED_LIST = [ + 'cohort_config', 'xml_attributes', 'start', 'end', diff --git a/common/lib/django_test_client_utils.py b/common/lib/django_test_client_utils.py new file mode 100644 index 0000000000..47d57dec65 --- /dev/null +++ b/common/lib/django_test_client_utils.py @@ -0,0 +1,49 @@ +""" +This file includes the monkey-patch for requests' PATCH method, as we are using +older version of django that does not contains the PATCH method in its test client. +""" +from __future__ import unicode_literals + +from urlparse import urlparse + +from django.test.client import RequestFactory, Client, FakePayload + + +BOUNDARY = 'BoUnDaRyStRiNg' +MULTIPART_CONTENT = 'multipart/form-data; boundary=%s' % BOUNDARY + + +def request_factory_patch(self, path, data={}, content_type=MULTIPART_CONTENT, **extra): + """ + Construct a PATCH request. + """ + patch_data = self._encode_data(data, content_type) + + parsed = urlparse(path) + r = { + 'CONTENT_LENGTH': len(patch_data), + 'CONTENT_TYPE': content_type, + 'PATH_INFO': self._get_path(parsed), + 'QUERY_STRING': parsed[4], + 'REQUEST_METHOD': 'PATCH', + 'wsgi.input': FakePayload(patch_data), + } + r.update(extra) + return self.request(**r) + + +def client_patch(self, path, data={}, content_type=MULTIPART_CONTENT, follow=False, **extra): + """ + Send a resource to the server using PATCH. + """ + response = super(Client, self).patch(path, data=data, content_type=content_type, **extra) + if follow: + response = self._handle_redirects(response, **extra) + return response + + +if not hasattr(RequestFactory, 'patch'): + setattr(RequestFactory, 'patch', request_factory_patch) + +if not hasattr(Client, 'patch'): + setattr(Client, 'patch', client_patch) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 085aca92dc..9d1e3ee99b 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -1046,6 +1046,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): def is_cohorted(self): """ Return whether the course is cohorted. + + Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings. """ config = self.cohort_config if config is None: @@ -1057,6 +1059,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): def auto_cohort(self): """ Return whether the course is auto-cohorted. + + Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings. """ if not self.is_cohorted: return False @@ -1070,6 +1074,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): Return the list of groups to put students into. Returns [] if not specified. Returns specified list even if is_cohorted and/or auto_cohort are false. + + Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings. """ if self.cohort_config is None: return [] @@ -1090,6 +1096,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): Return the set of discussions that is explicitly cohorted. It may be the empty set. Note that all inline discussions are automatically cohorted based on the course's is_cohorted setting. + + Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings. """ config = self.cohort_config if config is None: @@ -1103,6 +1111,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): This allow to change the default behavior of inline discussions cohorting. By setting this to False, all inline discussions are non-cohorted unless their ids are specified in cohorted_discussions. + + Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings. """ config = self.cohort_config if config is None: diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index c70e1f2742..f185186459 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -643,6 +643,9 @@ class ImportTestCase(BaseCourseTestCase): def test_cohort_config(self): """ Check that cohort config parsing works right. + + Note: The cohort config on the CourseModule is no longer used. + See openedx.core.djangoapps.course_groups.models.CourseCohortSettings. """ modulestore = XMLModuleStore(DATA_DIR, source_dirs=['toy']) diff --git a/common/test/acceptance/pages/lms/instructor_dashboard.py b/common/test/acceptance/pages/lms/instructor_dashboard.py index e86aceb8f1..e43d58c001 100644 --- a/common/test/acceptance/pages/lms/instructor_dashboard.py +++ b/common/test/acceptance/pages/lms/instructor_dashboard.py @@ -6,7 +6,7 @@ Instructor (2) dashboard page. from bok_choy.page_object import PageObject from .course_page import CoursePage import os -from bok_choy.promise import EmptyPromise +from bok_choy.promise import EmptyPromise, Promise from ...tests.helpers import select_option_by_text, get_selected_option_text, get_options @@ -28,6 +28,15 @@ class InstructorDashboardPage(CoursePage): membership_section.wait_for_page() return membership_section + def select_cohort_management(self): + """ + Selects the cohort management tab and returns the CohortManagementSection + """ + self.q(css='a[data-section=cohort_management]').first.click() + cohort_management_section = CohortManagementSection(self.browser) + cohort_management_section.wait_for_page() + return cohort_management_section + def select_data_download(self): """ Selects the data download tab and returns a DataDownloadPage. @@ -84,16 +93,10 @@ class MembershipPage(PageObject): """ return MembershipPageAutoEnrollSection(self.browser) - def select_cohort_management_section(self): - """ - Returns the MembershipPageCohortManagementSection page object. - """ - return MembershipPageCohortManagementSection(self.browser) - -class MembershipPageCohortManagementSection(PageObject): +class CohortManagementSection(PageObject): """ - The cohort management subsection of the Membership section of the Instructor dashboard. + The Cohort Management section of the Instructor dashboard. """ url = None csv_browse_button_selector_css = '.csv-upload #file-upload-form-file' @@ -101,21 +104,31 @@ class MembershipPageCohortManagementSection(PageObject): content_group_selector_css = 'select.input-cohort-group-association' no_content_group_button_css = '.cohort-management-details-association-course input.radio-no' select_content_group_button_css = '.cohort-management-details-association-course input.radio-yes' + assignment_type_buttons_css = '.cohort-management-assignment-type-settings input' + discussion_form_selectors = { + 'course-wide': '.cohort-course-wide-discussions-form', + 'inline': '.cohort-inline-discussions-form' + } def is_browser_on_page(self): - return self.q(css='.cohort-management.membership-section').present + return self.q(css='.cohort-management').present def _bounded_selector(self, selector): """ Return `selector`, but limited to the cohort management context. """ - return '.cohort-management.membership-section {}'.format(selector) + return '.cohort-management {}'.format(selector) def _get_cohort_options(self): """ Returns the available options in the cohort dropdown, including the initial "Select a cohort". """ - return self.q(css=self._bounded_selector("#cohort-select option")) + def check_func(): + """Promise Check Function""" + query = self.q(css=self._bounded_selector("#cohort-select option")) + return len(query) > 0, query + + return Promise(check_func, "Waiting for cohort selector to populate").fulfill() def _cohort_name(self, label): """ @@ -129,6 +142,41 @@ class MembershipPageCohortManagementSection(PageObject): """ return int(label.split(' (')[1].split(')')[0]) + def save_cohort_settings(self): + """ + Click on Save button shown after click on Settings tab or when we add a new cohort. + """ + self.q(css=self._bounded_selector("div.form-actions .action-save")).first.click() + + @property + def is_assignment_settings_disabled(self): + """ + Check if assignment settings are disabled. + """ + attributes = self.q(css=self._bounded_selector('.cohort-management-assignment-type-settings')).attrs('class') + if 'is-disabled' in attributes[0].split(): + return True + + return False + + @property + def assignment_settings_message(self): + """ + Return assignment settings disabled message in case of default cohort. + """ + query = self.q(css=self._bounded_selector('.copy-error')) + if query.visible: + return query.text[0] + + return '' + + @property + def cohort_name_in_header(self): + """ + Return cohort name as shown in cohort header. + """ + return self._cohort_name(self.q(css=self._bounded_selector(".group-header-title .title-value")).text[0]) + def get_cohorts(self): """ Returns, as a list, the names of the available cohorts in the drop-down, filtering out "Select a cohort". @@ -142,10 +190,6 @@ class MembershipPageCohortManagementSection(PageObject): """ Returns the name of the selected cohort. """ - EmptyPromise( - lambda: len(self._get_cohort_options().results) > 0, - "Waiting for cohort selector to populate" - ).fulfill() return self._cohort_name( self._get_cohort_options().filter(lambda el: el.is_selected()).first.text[0] ) @@ -162,10 +206,6 @@ class MembershipPageCohortManagementSection(PageObject): """ Selects the given cohort in the drop-down. """ - EmptyPromise( - lambda: cohort_name in self.get_cohorts(), - "Waiting for cohort selector to populate" - ).fulfill() # Note: can't use Select to select by text because the count is also included in the displayed text. self._get_cohort_options().filter( lambda el: self._cohort_name(el.text) == cohort_name @@ -176,20 +216,48 @@ class MembershipPageCohortManagementSection(PageObject): "Waiting to confirm cohort has been selected" ).fulfill() - def add_cohort(self, cohort_name, content_group=None): + def set_cohort_name(self, cohort_name): + """ + Set Cohort Name. + """ + textinput = self.q(css=self._bounded_selector("#cohort-name")).results[0] + textinput.clear() + textinput.send_keys(cohort_name) + + def set_assignment_type(self, assignment_type): + """ + Set assignment type for selected cohort. + + Arguments: + assignment_type (str): Should be 'random' or 'manual' + """ + css = self._bounded_selector(self.assignment_type_buttons_css) + self.q(css=css).filter(lambda el: el.get_attribute('value') == assignment_type).first.click() + + def add_cohort(self, cohort_name, content_group=None, assignment_type=None): """ Adds a new manual cohort with the specified name. If a content group should also be associated, the name of the content group should be specified. """ - create_buttons = self.q(css=self._bounded_selector(".action-create")) + add_cohort_selector = self._bounded_selector(".action-create") + + # We need to wait because sometime add cohort button is not in a state to be clickable. + self.wait_for_element_presence(add_cohort_selector, 'Add Cohort button is present.') + create_buttons = self.q(css=add_cohort_selector) # There are 2 create buttons on the page. The second one is only present when no cohort yet exists # (in which case the first is not visible). Click on the last present create button. create_buttons.results[len(create_buttons.results) - 1].click() textinput = self.q(css=self._bounded_selector("#cohort-name")).results[0] textinput.send_keys(cohort_name) + + # Manual assignment type will be selected by default for a new cohort + # if we are not setting the assignment type explicitly + if assignment_type: + self.set_assignment_type(assignment_type) + if content_group: self._select_associated_content_group(content_group) - self.q(css=self._bounded_selector("div.form-actions .action-save")).first.click() + self.save_cohort_settings() def get_cohort_group_setup(self): """ @@ -200,6 +268,12 @@ class MembershipPageCohortManagementSection(PageObject): def select_edit_settings(self): self.q(css=self._bounded_selector(".action-edit")).first.click() + def select_manage_settings(self): + """ + Click on Manage Students Tab under cohort management section. + """ + self.q(css=self._bounded_selector(".tab-manage_students")).first.click() + def add_students_to_selected_cohort(self, users): """ Adds a list of users (either usernames or email addresses) to the currently selected cohort. @@ -245,22 +319,14 @@ class MembershipPageCohortManagementSection(PageObject): return None return get_selected_option_text(self.q(css=self._bounded_selector(self.content_group_selector_css))) - def verify_cohort_content_group_selected(self, content_group=None): + def get_cohort_associated_assignment_type(self): """ - Waits for the expected content_group (or none) to show as selected for - cohort associated content group. + Returns the assignment type associated with the cohort currently being edited. """ - if content_group: - self.wait_for( - lambda: unicode( - self.q(css='select.input-cohort-group-association option:checked').text[0] - ) == content_group, - "Cohort group has been selected." - ) - else: - self.wait_for_element_visibility( - '.cohort-management-details-association-course input.radio-no:checked', - 'Radio button "No content group" has been selected.') + self.select_cohort_settings() + css_selector = self._bounded_selector(self.assignment_type_buttons_css) + radio_button = self.q(css=css_selector).filter(lambda el: el.is_selected()).results[0] + return radio_button.get_attribute('value') def set_cohort_associated_content_group(self, content_group=None, select_settings=True): """ @@ -274,8 +340,7 @@ class MembershipPageCohortManagementSection(PageObject): self.q(css=self._bounded_selector(self.no_content_group_button_css)).first.click() else: self._select_associated_content_group(content_group) - self.q(css=self._bounded_selector("div.form-actions .action-save")).first.click() - self.verify_cohort_content_group_selected(content_group) + self.save_cohort_settings() def _select_associated_content_group(self, content_group): """ @@ -390,6 +455,145 @@ class MembershipPageCohortManagementSection(PageObject): file_input.send_keys(path) self.q(css=self._bounded_selector(self.csv_upload_button_selector_css)).first.click() + @property + def is_cohorted(self): + """ + Returns the state of `Enable Cohorts` checkbox state. + """ + return self.q(css=self._bounded_selector('.cohorts-state')).selected + + @is_cohorted.setter + def is_cohorted(self, state): + """ + Check/Uncheck the `Enable Cohorts` checkbox state. + """ + if state != self.is_cohorted: + self.q(css=self._bounded_selector('.cohorts-state')).first.click() + + def toggles_showing_of_discussion_topics(self): + """ + Shows the discussion topics. + """ + EmptyPromise( + lambda: self.q(css=self._bounded_selector('.toggle-cohort-management-discussions')).results != 0, + "Waiting for discussion section to show" + ).fulfill() + + # If the discussion topic section has not yet been toggled on, click on the toggle link. + self.q(css=self._bounded_selector(".toggle-cohort-management-discussions")).click() + + def discussion_topics_visible(self): + """ + Returns the visibility status of cohort discussion controls. + """ + EmptyPromise( + lambda: self.q(css=self._bounded_selector('.cohort-discussions-nav')).results != 0, + "Waiting for discussion section to show" + ).fulfill() + + return (self.q(css=self._bounded_selector('.cohort-course-wide-discussions-nav')).visible and + self.q(css=self._bounded_selector('.cohort-inline-discussions-nav')).visible) + + def select_discussion_topic(self, key): + """ + Selects discussion topic checkbox by clicking on it. + """ + self.q(css=self._bounded_selector(".check-discussion-subcategory-%s" % key)).first.click() + + def select_always_inline_discussion(self): + """ + Selects the always_cohort_inline_discussions radio button. + """ + self.q(css=self._bounded_selector(".check-all-inline-discussions")).first.click() + + def always_inline_discussion_selected(self): + """ + Returns the checked always_cohort_inline_discussions radio button. + """ + return self.q(css=self._bounded_selector(".check-all-inline-discussions:checked")) + + def cohort_some_inline_discussion_selected(self): + """ + Returns the checked some_cohort_inline_discussions radio button. + """ + return self.q(css=self._bounded_selector(".check-cohort-inline-discussions:checked")) + + def select_cohort_some_inline_discussion(self): + """ + Selects the cohort_some_inline_discussions radio button. + """ + self.q(css=self._bounded_selector(".check-cohort-inline-discussions")).first.click() + + def inline_discussion_topics_disabled(self): + """ + Returns the status of inline discussion topics, enabled or disabled. + """ + inline_topics = self.q(css=self._bounded_selector('.check-discussion-subcategory-inline')) + return all(topic.get_attribute('disabled') == 'true' for topic in inline_topics) + + def is_save_button_disabled(self, key): + """ + Returns the status for form's save button, enabled or disabled. + """ + save_button_css = '%s %s' % (self.discussion_form_selectors[key], '.action-save') + disabled = self.q(css=self._bounded_selector(save_button_css)).attrs('disabled') + return disabled[0] == 'true' + + def is_category_selected(self): + """ + Returns the status for category checkboxes. + """ + return self.q(css=self._bounded_selector('.check-discussion-category:checked')).is_present() + + def get_cohorted_topics_count(self, key): + """ + Returns the count for cohorted topics. + """ + cohorted_topics = self.q(css=self._bounded_selector('.check-discussion-subcategory-%s:checked' % key)) + return len(cohorted_topics.results) + + def save_discussion_topics(self, key): + """ + Saves the discussion topics. + """ + save_button_css = '%s %s' % (self.discussion_form_selectors[key], '.action-save') + self.q(css=self._bounded_selector(save_button_css)).first.click() + + def get_cohort_discussions_message(self, key, msg_type="confirmation"): + """ + Returns the message related to modifying discussion topics. + """ + title_css = "%s .message-%s .message-title" % (self.discussion_form_selectors[key], msg_type) + + EmptyPromise( + lambda: self.q(css=self._bounded_selector(title_css)), + "Waiting for message to appear" + ).fulfill() + + message_title = self.q(css=self._bounded_selector(title_css)) + + if len(message_title.results) == 0: + return '' + return message_title.first.text[0] + + def cohort_discussion_heading_is_visible(self, key): + """ + Returns the visibility of discussion topic headings. + """ + form_heading_css = '%s %s' % (self.discussion_form_selectors[key], '.subsection-title') + discussion_heading = self.q(css=self._bounded_selector(form_heading_css)) + + if len(discussion_heading) == 0: + return False + return discussion_heading.first.text[0] + + def cohort_management_controls_visible(self): + """ + Return the visibility status of cohort management controls(cohort selector section etc). + """ + return (self.q(css=self._bounded_selector('.cohort-management-nav')).visible and + self.q(css=self._bounded_selector('.wrapper-cohort-supplemental')).visible) + class MembershipPageAutoEnrollSection(PageObject): """ diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py index 126971bcd7..d556e4b72c 100644 --- a/common/test/acceptance/pages/studio/settings_advanced.py +++ b/common/test/acceptance/pages/studio/settings_advanced.py @@ -154,7 +154,6 @@ class AdvancedSettingsPage(CoursePage): 'cert_name_long', 'cert_name_short', 'certificates_display_behavior', - 'cohort_config', 'course_image', 'cosmetic_display_price', 'advertised_start', diff --git a/common/test/acceptance/tests/discussion/helpers.py b/common/test/acceptance/tests/discussion/helpers.py index f0722cd236..269148b20d 100644 --- a/common/test/acceptance/tests/discussion/helpers.py +++ b/common/test/acceptance/tests/discussion/helpers.py @@ -60,20 +60,17 @@ class CohortTestMixin(object): """ Disables cohorting for the current course fixture. """ - course_fixture._update_xblock(course_fixture._course_location, { - "metadata": { - u"cohort_config": { - "cohorted": False - }, - }, - }) + url = LMS_BASE_URL + "/courses/" + course_fixture._course_key + '/cohorts/settings' # pylint: disable=protected-access + data = json.dumps({'is_cohorted': False}) + response = course_fixture.session.patch(url, data=data, headers=course_fixture.headers) + self.assertTrue(response.ok, "Failed to disable cohorts") def add_manual_cohort(self, course_fixture, cohort_name): """ Adds a cohort by name, returning its ID. """ url = LMS_BASE_URL + "/courses/" + course_fixture._course_key + '/cohorts/' - data = json.dumps({"name": cohort_name}) + data = json.dumps({"name": cohort_name, 'assignment_type': 'manual'}) response = course_fixture.session.post(url, data=data, headers=course_fixture.headers) self.assertTrue(response.ok, "Failed to create cohort") return response.json()['id'] diff --git a/common/test/acceptance/tests/discussion/test_cohort_management.py b/common/test/acceptance/tests/discussion/test_cohort_management.py index 686e1d8c17..48f452f0fa 100644 --- a/common/test/acceptance/tests/discussion/test_cohort_management.py +++ b/common/test/acceptance/tests/discussion/test_cohort_management.py @@ -11,7 +11,7 @@ from nose.plugins.attrib import attr from .helpers import CohortTestMixin from ..helpers import UniqueCourseTest, EventsTestMixin, create_user_partition_json from xmodule.partitions.partitions import Group -from ...fixtures.course import CourseFixture +from ...fixtures.course import CourseFixture, XBlockFixtureDesc from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.instructor_dashboard import InstructorDashboardPage, DataDownloadPage from ...pages.studio.settings_advanced import AdvancedSettingsPage @@ -63,8 +63,7 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin # go to the membership page on the instructor dashboard self.instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id) self.instructor_dashboard_page.visit() - membership_page = self.instructor_dashboard_page.select_membership() - self.cohort_management_page = membership_page.select_cohort_management_section() + self.cohort_management_page = self.instructor_dashboard_page.select_cohort_management() def verify_cohort_description(self, cohort_name, expected_description): """ @@ -123,22 +122,6 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin ) group_settings_page.wait_for_page() - def test_link_to_studio(self): - """ - Scenario: a link is present from the cohort configuration in the instructor dashboard - to the Studio Advanced Settings. - - Given I have a course with a cohort defined - When I view the cohort in the LMS instructor dashboard - There is a link to take me to the Studio Advanced Settings for the course - """ - self.cohort_management_page.select_cohort(self.manual_cohort_name) - self.cohort_management_page.select_edit_settings() - advanced_settings_page = AdvancedSettingsPage( - self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'] - ) - advanced_settings_page.wait_for_page() - def test_add_students_to_cohort_success(self): """ Scenario: When students are added to a cohort, the appropriate notification is shown. @@ -250,36 +233,47 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin self.cohort_management_page.get_cohort_student_input_field_value() ) - def test_add_new_cohort(self): - """ - Scenario: A new manual cohort can be created, and a student assigned to it. + def _verify_cohort_settings( + self, + cohort_name, + assignment_type=None, + new_cohort_name=None, + new_assignment_type=None, + verify_updated=False + ): - Given I have a course with a user in the course - When I add a new manual cohort to the course via the LMS instructor dashboard - Then the new cohort is displayed and has no users in it - And when I add the user to the new cohort - Then the cohort has 1 user - And appropriate events have been emitted + """ + Create a new cohort and verify the new and existing settings. """ start_time = datetime.now(UTC) - new_cohort = str(uuid.uuid4().get_hex()[0:20]) - self.assertFalse(new_cohort in self.cohort_management_page.get_cohorts()) - self.cohort_management_page.add_cohort(new_cohort) + self.assertFalse(cohort_name in self.cohort_management_page.get_cohorts()) + self.cohort_management_page.add_cohort(cohort_name, assignment_type=assignment_type) # After adding the cohort, it should automatically be selected EmptyPromise( - lambda: new_cohort == self.cohort_management_page.get_selected_cohort(), "Waiting for new cohort to appear" + lambda: cohort_name == self.cohort_management_page.get_selected_cohort(), "Waiting for new cohort to appear" ).fulfill() self.assertEqual(0, self.cohort_management_page.get_selected_cohort_count()) + # After adding the cohort, it should automatically be selected and its + # assignment_type should be "manual" as this is the default assignment type + _assignment_type = assignment_type or 'manual' + msg = "Waiting for currently selected cohort assignment type" + EmptyPromise( + lambda: _assignment_type == self.cohort_management_page.get_cohort_associated_assignment_type(), msg + ).fulfill() + # Go back to Manage Students Tab + self.cohort_management_page.select_manage_settings() self.cohort_management_page.add_students_to_selected_cohort([self.instructor_name]) # Wait for the number of users in the cohort to change, indicating that the add operation is complete. EmptyPromise( lambda: 1 == self.cohort_management_page.get_selected_cohort_count(), 'Waiting for student to be added' ).fulfill() + self.assertFalse(self.cohort_management_page.is_assignment_settings_disabled) + self.assertEqual('', self.cohort_management_page.assignment_settings_message) self.assertEqual( self.event_collection.find({ "name": "edx.cohort.created", "time": {"$gt": start_time}, - "event.cohort_name": new_cohort, + "event.cohort_name": cohort_name, }).count(), 1 ) @@ -287,11 +281,174 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin self.event_collection.find({ "name": "edx.cohort.creation_requested", "time": {"$gt": start_time}, - "event.cohort_name": new_cohort, + "event.cohort_name": cohort_name, }).count(), 1 ) + if verify_updated: + self.cohort_management_page.select_cohort(cohort_name) + self.cohort_management_page.select_cohort_settings() + self.cohort_management_page.set_cohort_name(new_cohort_name) + self.cohort_management_page.set_assignment_type(new_assignment_type) + self.cohort_management_page.save_cohort_settings() + + # If cohort name is empty, then we should get/see an error message. + if not new_cohort_name: + confirmation_messages = self.cohort_management_page.get_cohort_settings_messages(type='error') + self.assertEqual( + ["The cohort cannot be saved", "You must specify a name for the cohort"], + confirmation_messages + ) + else: + confirmation_messages = self.cohort_management_page.get_cohort_settings_messages() + self.assertEqual(["Saved cohort"], confirmation_messages) + self.assertEqual(new_cohort_name, self.cohort_management_page.cohort_name_in_header) + self.assertTrue(new_cohort_name in self.cohort_management_page.get_cohorts()) + self.assertEqual(1, self.cohort_management_page.get_selected_cohort_count()) + self.assertEqual( + new_assignment_type, + self.cohort_management_page.get_cohort_associated_assignment_type() + ) + + def test_add_new_cohort(self): + """ + Scenario: A new manual cohort can be created, and a student assigned to it. + + Given I have a course with a user in the course + When I add a new manual cohort to the course via the LMS instructor dashboard + Then the new cohort is displayed and has no users in it + And assignment type of displayed cohort to "manual" because this is the default + And when I add the user to the new cohort + Then the cohort has 1 user + And appropriate events have been emitted + """ + cohort_name = str(uuid.uuid4().get_hex()[0:20]) + self._verify_cohort_settings(cohort_name=cohort_name, assignment_type=None) + + def test_add_new_cohort_with_manual_assignment_type(self): + """ + Scenario: A new cohort with manual assignment type can be created, and a student assigned to it. + + Given I have a course with a user in the course + When I add a new manual cohort with manual assignment type to the course via the LMS instructor dashboard + Then the new cohort is displayed and has no users in it + And assignment type of displayed cohort is "manual" + And when I add the user to the new cohort + Then the cohort has 1 user + And appropriate events have been emitted + """ + cohort_name = str(uuid.uuid4().get_hex()[0:20]) + self._verify_cohort_settings(cohort_name=cohort_name, assignment_type='manual') + + def test_add_new_cohort_with_random_assignment_type(self): + """ + Scenario: A new cohort with random assignment type can be created, and a student assigned to it. + + Given I have a course with a user in the course + When I add a new manual cohort with random assignment type to the course via the LMS instructor dashboard + Then the new cohort is displayed and has no users in it + And assignment type of displayed cohort is "random" + And when I add the user to the new cohort + Then the cohort has 1 user + And appropriate events have been emitted + """ + cohort_name = str(uuid.uuid4().get_hex()[0:20]) + self._verify_cohort_settings(cohort_name=cohort_name, assignment_type='random') + + def test_update_existing_cohort_settings(self): + """ + Scenario: Update existing cohort settings(cohort name, assignment type) + + Given I have a course with a user in the course + When I add a new cohort with random assignment type to the course via the LMS instructor dashboard + Then the new cohort is displayed and has no users in it + And assignment type of displayed cohort is "random" + And when I add the user to the new cohort + Then the cohort has 1 user + And appropriate events have been emitted + Then I select the cohort (that you just created) from existing cohorts + Then I change its name and assignment type set to "manual" + Then I Save the settings + And cohort with new name is present in cohorts dropdown list + And cohort assignment type should be "manual" + """ + cohort_name = str(uuid.uuid4().get_hex()[0:20]) + new_cohort_name = '{old}__NEW'.format(old=cohort_name) + self._verify_cohort_settings( + cohort_name=cohort_name, + assignment_type='random', + new_cohort_name=new_cohort_name, + new_assignment_type='manual', + verify_updated=True + ) + + def test_update_existing_cohort_settings_with_empty_cohort_name(self): + """ + Scenario: Update existing cohort settings(cohort name, assignment type). + + Given I have a course with a user in the course + When I add a new cohort with random assignment type to the course via the LMS instructor dashboard + Then the new cohort is displayed and has no users in it + And assignment type of displayed cohort is "random" + And when I add the user to the new cohort + Then the cohort has 1 user + And appropriate events have been emitted + Then I select a cohort from existing cohorts + Then I set its name as empty string and assignment type set to "manual" + And I click on Save button + Then I should see an error message + """ + cohort_name = str(uuid.uuid4().get_hex()[0:20]) + new_cohort_name = '' + self._verify_cohort_settings( + cohort_name=cohort_name, + assignment_type='random', + new_cohort_name=new_cohort_name, + new_assignment_type='manual', + verify_updated=True + ) + + def test_default_cohort_assignment_settings(self): + """ + Scenario: Cohort assignment settings are disabled for default cohort. + + Given I have a course with a user in the course + And I have added a manual cohort + And I have added a random cohort + When I select the random cohort + Then cohort assignment settings are disabled + """ + self.cohort_management_page.select_cohort("AutoCohort1") + self.cohort_management_page.select_cohort_settings() + + self.assertTrue(self.cohort_management_page.is_assignment_settings_disabled) + + message = "There must be one cohort to which students can automatically be assigned." + self.assertEqual(message, self.cohort_management_page.assignment_settings_message) + + def test_cohort_enable_disable(self): + """ + Scenario: Cohort Enable/Disable checkbox related functionality is working as intended. + + Given I have a cohorted course with a user. + And I can see the `Enable Cohorts` checkbox is checked. + And cohort management controls are visible. + When I uncheck the `Enable Cohorts` checkbox. + Then cohort management controls are not visible. + And When I reload the page. + Then I can see the `Enable Cohorts` checkbox is unchecked. + And cohort management controls are not visible. + """ + self.assertTrue(self.cohort_management_page.is_cohorted) + self.assertTrue(self.cohort_management_page.cohort_management_controls_visible()) + self.cohort_management_page.is_cohorted = False + self.assertFalse(self.cohort_management_page.cohort_management_controls_visible()) + self.browser.refresh() + self.cohort_management_page.wait_for_page() + self.assertFalse(self.cohort_management_page.is_cohorted) + self.assertFalse(self.cohort_management_page.cohort_management_controls_visible()) + def test_link_to_data_download(self): """ Scenario: a link is present from the cohort configuration in @@ -462,6 +619,297 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin self.assertEquals(expected_message, messages[0]) +@attr('shard_3') +class CohortDiscussionTopicsTest(UniqueCourseTest, CohortTestMixin): + """ + Tests for cohorting the inline and course-wide discussion topics. + """ + def setUp(self): + """ + Set up a discussion topics + """ + super(CohortDiscussionTopicsTest, self).setUp() + + self.discussion_id = "test_discussion_{}".format(uuid.uuid4().hex) + self.course_fixture = CourseFixture(**self.course_info).add_children( + XBlockFixtureDesc("chapter", "Test Section").add_children( + XBlockFixtureDesc("sequential", "Test Subsection").add_children( + XBlockFixtureDesc("vertical", "Test Unit").add_children( + XBlockFixtureDesc( + "discussion", + "Test Discussion", + metadata={"discussion_id": self.discussion_id} + ) + ) + ) + ) + ).install() + + # create course with single cohort and two content groups (user_partition of type "cohort") + self.cohort_name = "OnlyCohort" + self.setup_cohort_config(self.course_fixture) + self.cohort_id = self.add_manual_cohort(self.course_fixture, self.cohort_name) + + # login as an instructor + self.instructor_name = "instructor_user" + self.instructor_id = AutoAuthPage( + self.browser, username=self.instructor_name, email="instructor_user@example.com", + course_id=self.course_id, staff=True + ).visit().get_user_id() + + # go to the membership page on the instructor dashboard + self.instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id) + self.instructor_dashboard_page.visit() + self.cohort_management_page = self.instructor_dashboard_page.select_cohort_management() + self.cohort_management_page.wait_for_page() + + self.course_wide_key = 'course-wide' + self.inline_key = 'inline' + + def cohort_discussion_topics_are_visible(self): + """ + Assert that discussion topics are visible with appropriate content. + """ + self.cohort_management_page.toggles_showing_of_discussion_topics() + self.assertTrue(self.cohort_management_page.discussion_topics_visible()) + + self.assertEqual( + "Course-Wide Discussion Topics", + self.cohort_management_page.cohort_discussion_heading_is_visible(self.course_wide_key) + ) + self.assertTrue(self.cohort_management_page.is_save_button_disabled(self.course_wide_key)) + + self.assertEqual( + "Content-Specific Discussion Topics", + self.cohort_management_page.cohort_discussion_heading_is_visible(self.inline_key) + ) + self.assertTrue(self.cohort_management_page.is_save_button_disabled(self.inline_key)) + + def save_and_verify_discussion_topics(self, key): + """ + Saves the discussion topics and the verify the changes. + """ + # click on the inline save button. + self.cohort_management_page.save_discussion_topics(key) + + # verifies that changes saved successfully. + confirmation_message = self.cohort_management_page.get_cohort_discussions_message(key=key) + self.assertEqual("Your changes have been saved.", confirmation_message) + + # save button disabled again. + self.assertTrue(self.cohort_management_page.is_save_button_disabled(key)) + + def reload_page(self): + """ + Refresh the page. + """ + self.browser.refresh() + self.cohort_management_page.wait_for_page() + + self.instructor_dashboard_page.select_cohort_management() + self.cohort_management_page.wait_for_page() + + self.cohort_discussion_topics_are_visible() + + def verify_discussion_topics_after_reload(self, key, cohorted_topics): + """ + Verifies the changed topics. + """ + self.reload_page() + self.assertEqual(self.cohort_management_page.get_cohorted_topics_count(key), cohorted_topics) + + def test_cohort_course_wide_discussion_topic(self): + """ + Scenario: cohort a course-wide discussion topic. + + Given I have a course with a cohort defined, + And a course-wide discussion with disabled Save button. + When I click on the course-wide discussion topic + Then I see the enabled save button + When I click on save button + Then I see success message + When I reload the page + Then I see the discussion topic selected + """ + self.cohort_discussion_topics_are_visible() + + cohorted_topics_before = self.cohort_management_page.get_cohorted_topics_count(self.course_wide_key) + self.cohort_management_page.select_discussion_topic(self.course_wide_key) + + self.assertFalse(self.cohort_management_page.is_save_button_disabled(self.course_wide_key)) + + self.save_and_verify_discussion_topics(key=self.course_wide_key) + cohorted_topics_after = self.cohort_management_page.get_cohorted_topics_count(self.course_wide_key) + + self.assertNotEqual(cohorted_topics_before, cohorted_topics_after) + + self.verify_discussion_topics_after_reload(self.course_wide_key, cohorted_topics_after) + + def test_always_cohort_inline_topic_enabled(self): + """ + Scenario: Select the always_cohort_inline_topics radio button + + Given I have a course with a cohort defined, + And a inline discussion topic with disabled Save button. + When I click on always_cohort_inline_topics + Then I see enabled save button + And I see disabled inline discussion topics + When I reload the page + Then I see the option enabled + """ + self.cohort_discussion_topics_are_visible() + + # enable always inline discussion topics. + self.cohort_management_page.select_always_inline_discussion() + + self.assertTrue(self.cohort_management_page.inline_discussion_topics_disabled()) + + self.reload_page() + self.assertIsNotNone(self.cohort_management_page.always_inline_discussion_selected()) + + def test_cohort_some_inline_topics_enabled(self): + """ + Scenario: Select the cohort_some_inline_topics radio button + + Given I have a course with a cohort defined, + And a inline discussion topic with disabled Save button. + When I click on cohort_some_inline_topics + Then I see enabled save button + And I see enabled inline discussion topics + When I reload the page + Then I see the option enabled + """ + self.cohort_discussion_topics_are_visible() + + # enable some inline discussion topic radio button. + self.cohort_management_page.select_cohort_some_inline_discussion() + # I see that save button is enabled + self.assertFalse(self.cohort_management_page.is_save_button_disabled(self.inline_key)) + # I see that inline discussion topics are enabled + self.assertFalse(self.cohort_management_page.inline_discussion_topics_disabled()) + + self.reload_page() + self.assertIsNotNone(self.cohort_management_page.cohort_some_inline_discussion_selected()) + + def test_cohort_inline_discussion_topic(self): + """ + Scenario: cohort inline discussion topic. + + Given I have a course with a cohort defined, + And a inline discussion topic with disabled Save button. + When I click on cohort_some_inline_discussion_topics + Then I see enabled saved button + And When I click on inline discussion topic + And I see enabled save button + And When i click save button + Then I see success message + When I reload the page + Then I see the discussion topic selected + """ + self.cohort_discussion_topics_are_visible() + + # select some inline discussion topics radio button. + self.cohort_management_page.select_cohort_some_inline_discussion() + + cohorted_topics_before = self.cohort_management_page.get_cohorted_topics_count(self.inline_key) + # check the discussion topic. + self.cohort_management_page.select_discussion_topic(self.inline_key) + + # Save button enabled. + self.assertFalse(self.cohort_management_page.is_save_button_disabled(self.inline_key)) + + # verifies that changes saved successfully. + self.save_and_verify_discussion_topics(key=self.inline_key) + + cohorted_topics_after = self.cohort_management_page.get_cohorted_topics_count(self.inline_key) + self.assertNotEqual(cohorted_topics_before, cohorted_topics_after) + + self.verify_discussion_topics_after_reload(self.inline_key, cohorted_topics_after) + + def test_verify_that_selecting_the_final_child_selects_category(self): + """ + Scenario: Category should be selected on selecting final child. + + Given I have a course with a cohort defined, + And a inline discussion with disabled Save button. + When I click on child topics + Then I see enabled saved button + Then I see parent category to be checked. + """ + self.cohort_discussion_topics_are_visible() + + # enable some inline discussion topics. + self.cohort_management_page.select_cohort_some_inline_discussion() + + # category should not be selected. + self.assertFalse(self.cohort_management_page.is_category_selected()) + + # check the discussion topic. + self.cohort_management_page.select_discussion_topic(self.inline_key) + + # verify that category is selected. + self.assertTrue(self.cohort_management_page.is_category_selected()) + + def test_verify_that_deselecting_the_final_child_deselects_category(self): + """ + Scenario: Category should be deselected on deselecting final child. + + Given I have a course with a cohort defined, + And a inline discussion with disabled Save button. + When I click on final child topics + Then I see enabled saved button + Then I see parent category to be deselected. + """ + self.cohort_discussion_topics_are_visible() + + # enable some inline discussion topics. + self.cohort_management_page.select_cohort_some_inline_discussion() + + # category should not be selected. + self.assertFalse(self.cohort_management_page.is_category_selected()) + + # check the discussion topic. + self.cohort_management_page.select_discussion_topic(self.inline_key) + + # verify that category is selected. + self.assertTrue(self.cohort_management_page.is_category_selected()) + + # un-check the discussion topic. + self.cohort_management_page.select_discussion_topic(self.inline_key) + + # category should not be selected. + self.assertFalse(self.cohort_management_page.is_category_selected()) + + def test_verify_that_correct_subset_of_category_being_selected_after_save(self): + """ + Scenario: Category should be selected on selecting final child. + + Given I have a course with a cohort defined, + And a inline discussion with disabled Save button. + When I click on child topics + Then I see enabled saved button + When I select subset of category + And I click on save button + Then I see success message with + same sub-category being selected + """ + self.cohort_discussion_topics_are_visible() + + # enable some inline discussion topics. + self.cohort_management_page.select_cohort_some_inline_discussion() + + # category should not be selected. + self.assertFalse(self.cohort_management_page.is_category_selected()) + + cohorted_topics_after = self.cohort_management_page.get_cohorted_topics_count(self.inline_key) + + # verifies that changes saved successfully. + self.save_and_verify_discussion_topics(key=self.inline_key) + + # verify changes after reload. + self.verify_discussion_topics_after_reload(self.inline_key, cohorted_topics_after) + + @attr('shard_3') class CohortContentGroupAssociationTest(UniqueCourseTest, CohortTestMixin): """ @@ -504,8 +952,7 @@ class CohortContentGroupAssociationTest(UniqueCourseTest, CohortTestMixin): # go to the membership page on the instructor dashboard self.instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id) self.instructor_dashboard_page.visit() - membership_page = self.instructor_dashboard_page.select_membership() - self.cohort_management_page = membership_page.select_cohort_management_section() + self.cohort_management_page = self.instructor_dashboard_page.select_cohort_management() def test_no_content_group_linked(self): """ diff --git a/common/test/acceptance/tests/test_cohorted_courseware.py b/common/test/acceptance/tests/test_cohorted_courseware.py index 00f42cd14e..11097684d5 100644 --- a/common/test/acceptance/tests/test_cohorted_courseware.py +++ b/common/test/acceptance/tests/test_cohorted_courseware.py @@ -2,18 +2,17 @@ End-to-end test for cohorted courseware. This uses both Studio and LMS. """ -from nose.plugins.attrib import attr import json +from nose.plugins.attrib import attr from studio.base_studio_test import ContainerBase from ..pages.studio.settings_group_configurations import GroupConfigurationsPage -from ..pages.studio.settings_advanced import AdvancedSettingsPage from ..pages.studio.auto_auth import AutoAuthPage as StudioAutoAuthPage from ..fixtures.course import XBlockFixtureDesc +from ..fixtures import LMS_BASE_URL from ..pages.studio.component_editor import ComponentVisibilityEditorView from ..pages.lms.instructor_dashboard import InstructorDashboardPage -from ..pages.lms.course_nav import CourseNavPage from ..pages.lms.courseware import CoursewarePage from ..pages.lms.auto_auth import AutoAuthPage as LmsAutoAuthPage from ..tests.lms.test_lms_user_preview import verify_expected_problem_visibility @@ -80,28 +79,14 @@ class EndToEndCohortedCoursewareTest(ContainerBase): ) ) - def enable_cohorts_in_course(self): + def enable_cohorting(self, course_fixture): """ - This turns on cohorts for the course. Currently this is still done through Advanced - Settings. Eventually it will be done in the LMS Instructor Dashboard. + Enables cohorting for the current course. """ - advanced_settings = AdvancedSettingsPage( - self.browser, - self.course_info['org'], - self.course_info['number'], - self.course_info['run'] - ) - - advanced_settings.visit() - cohort_config = '{"cohorted": true}' - advanced_settings.set('Cohort Configuration', cohort_config) - advanced_settings.refresh_and_wait_for_load() - - self.assertEquals( - json.loads(cohort_config), - json.loads(advanced_settings.get('Cohort Configuration')), - 'Wrong input for Cohort Configuration' - ) + url = LMS_BASE_URL + "/courses/" + course_fixture._course_key + '/cohorts/settings' # pylint: disable=protected-access + data = json.dumps({'is_cohorted': True}) + response = course_fixture.session.patch(url, data=data, headers=course_fixture.headers) + self.assertTrue(response.ok, "Failed to enable cohorts") def create_content_groups(self): """ @@ -154,8 +139,7 @@ class EndToEndCohortedCoursewareTest(ContainerBase): """ instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id) instructor_dashboard_page.visit() - membership_page = instructor_dashboard_page.select_membership() - cohort_management_page = membership_page.select_cohort_management_section() + cohort_management_page = instructor_dashboard_page.select_cohort_management() def add_cohort_with_student(cohort_name, content_group, student): cohort_management_page.add_cohort(cohort_name, content_group=content_group) @@ -220,7 +204,7 @@ class EndToEndCohortedCoursewareTest(ContainerBase): And the student in Cohort B can see all the problems except the one linked to Content Group A And the student in the default cohort can ony see the problem that is unlinked to any Content Group """ - self.enable_cohorts_in_course() + self.enable_cohorting(self.course_fixture) self.create_content_groups() self.link_problems_to_content_groups_and_publish() self.create_cohorts_and_assign_students() diff --git a/lms/djangoapps/django_comment_client/forum/tests.py b/lms/djangoapps/django_comment_client/forum/tests.py index 7242c12879..bbeb7dba09 100644 --- a/lms/djangoapps/django_comment_client/forum/tests.py +++ b/lms/djangoapps/django_comment_client/forum/tests.py @@ -309,18 +309,18 @@ class SingleThreadTestCase(ModuleStoreTestCase): @patch('requests.request') class SingleThreadQueryCountTestCase(ModuleStoreTestCase): """ - Ensures the number of modulestore queries is deterministic based on the - number of responses retrieved for a given discussion thread. + Ensures the number of modulestore queries and number of sql queries are + independent of the number of responses retrieved for a given discussion thread. """ MODULESTORE = TEST_DATA_MONGO_MODULESTORE @ddt.data( - # old mongo with cache: number of responses plus 17. TODO: O(n)! - (ModuleStoreEnum.Type.mongo, 1, 23, 18), - (ModuleStoreEnum.Type.mongo, 50, 366, 67), + # old mongo with cache: 15 + (ModuleStoreEnum.Type.mongo, 1, 21, 15, 40, 27), + (ModuleStoreEnum.Type.mongo, 50, 315, 15, 628, 27), # split mongo: 3 queries, regardless of thread response size. - (ModuleStoreEnum.Type.split, 1, 3, 3), - (ModuleStoreEnum.Type.split, 50, 3, 3), + (ModuleStoreEnum.Type.split, 1, 3, 3, 40, 27), + (ModuleStoreEnum.Type.split, 50, 3, 3, 628, 27), ) @ddt.unpack def test_number_of_mongo_queries( @@ -329,6 +329,8 @@ class SingleThreadQueryCountTestCase(ModuleStoreTestCase): num_thread_responses, num_uncached_mongo_calls, num_cached_mongo_calls, + num_uncached_sql_queries, + num_cached_sql_queries, mock_request ): with modulestore().default_store(default_store): @@ -370,15 +372,16 @@ class SingleThreadQueryCountTestCase(ModuleStoreTestCase): backend='django.core.cache.backends.dummy.DummyCache', LOCATION='single_thread_local_cache' ) - cached_calls = { - single_thread_dummy_cache: num_uncached_mongo_calls, - single_thread_local_cache: num_cached_mongo_calls - } - for single_thread_cache, expected_calls in cached_calls.items(): + cached_calls = [ + [single_thread_dummy_cache, num_uncached_mongo_calls, num_uncached_sql_queries], + [single_thread_local_cache, num_cached_mongo_calls, num_cached_sql_queries] + ] + for single_thread_cache, expected_mongo_calls, expected_sql_queries in cached_calls: single_thread_cache.clear() with patch("django_comment_client.permissions.CACHE", single_thread_cache): - with check_mongo_calls(expected_calls): - call_single_thread() + with self.assertNumQueries(expected_sql_queries): + with check_mongo_calls(expected_mongo_calls): + call_single_thread() single_thread_cache.clear() diff --git a/lms/djangoapps/django_comment_client/tests/test_utils.py b/lms/djangoapps/django_comment_client/tests/test_utils.py index 79d4cadf0e..81cff7d477 100644 --- a/lms/djangoapps/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/django_comment_client/tests/test_utils.py @@ -1,17 +1,24 @@ # -*- coding: utf-8 -*- -from datetime import datetime +import datetime import json +import mock from pytz import UTC +from django.utils.timezone import UTC as django_utc +from django_comment_client.tests.factories import RoleFactory +from django_comment_client.tests.unicode import UnicodeTestMixin +import django_comment_client.utils as utils from django.core.urlresolvers import reverse from django.test import TestCase from edxmako import add_lookup -import mock from django_comment_client.tests.factories import RoleFactory from django_comment_client.tests.unicode import UnicodeTestMixin from django_comment_client.tests.utils import ContentGroupTestCase import django_comment_client.utils as utils + +from courseware.tests.factories import InstructorFactory +from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings from student.tests.factories import UserFactory, CourseEnrollmentFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -174,12 +181,13 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): # This test needs to use a course that has already started -- # discussion topics only show up if the course has already started, # and the default start date for courses is Jan 1, 2030. - start=datetime(2012, 2, 3, tzinfo=UTC) + start=datetime.datetime(2012, 2, 3, tzinfo=UTC) ) # Courses get a default discussion topic on creation, so remove it self.course.discussion_topics = {} self.course.save() self.discussion_num = 0 + self.instructor = InstructorFactory(course_key=self.course.id) self.maxDiff = None # pylint: disable=invalid-name def create_discussion(self, discussion_category, discussion_target, **kwargs): @@ -193,6 +201,15 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): **kwargs ) + def assert_category_map_equals(self, expected, cohorted_if_in_list=False, exclude_unstarted=True): # pylint: disable=arguments-differ + """ + Asserts the expected map with the map returned by get_discussion_category_map method. + """ + self.assertEqual( + utils.get_discussion_category_map(self.course, self.instructor, cohorted_if_in_list, exclude_unstarted), + expected + ) + def test_empty(self): self.assert_category_map_equals({"entries": {}, "subcategories": {}, "children": []}) @@ -218,20 +235,35 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): check_cohorted_topics([]) # default (empty) cohort config - self.course.cohort_config = {"cohorted": False, "cohorted_discussions": []} + set_course_cohort_settings(course_key=self.course.id, is_cohorted=False, cohorted_discussions=[]) check_cohorted_topics([]) - self.course.cohort_config = {"cohorted": True, "cohorted_discussions": []} + set_course_cohort_settings(course_key=self.course.id, is_cohorted=True, cohorted_discussions=[]) check_cohorted_topics([]) - self.course.cohort_config = {"cohorted": True, "cohorted_discussions": ["Topic_B", "Topic_C"]} + set_course_cohort_settings( + course_key=self.course.id, + is_cohorted=True, + cohorted_discussions=["Topic_B", "Topic_C"], + always_cohort_inline_discussions=False, + ) check_cohorted_topics(["Topic_B", "Topic_C"]) - self.course.cohort_config = {"cohorted": True, "cohorted_discussions": ["Topic_A", "Some_Other_Topic"]} + set_course_cohort_settings( + course_key=self.course.id, + is_cohorted=True, + cohorted_discussions=["Topic_A", "Some_Other_Topic"], + always_cohort_inline_discussions=False, + ) check_cohorted_topics(["Topic_A"]) # unlikely case, but make sure it works. - self.course.cohort_config = {"cohorted": False, "cohorted_discussions": ["Topic_A"]} + set_course_cohort_settings( + course_key=self.course.id, + is_cohorted=False, + cohorted_discussions=["Topic_A"], + always_cohort_inline_discussions=False, + ) check_cohorted_topics([]) def test_single_inline(self): @@ -256,6 +288,85 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): } ) + def test_inline_with_always_cohort_inline_discussion_flag(self): + self.create_discussion("Chapter", "Discussion") + set_course_cohort_settings(course_key=self.course.id, is_cohorted=True) + + self.assert_category_map_equals( + { + "entries": {}, + "subcategories": { + "Chapter": { + "entries": { + "Discussion": { + "id": "discussion1", + "sort_key": None, + "is_cohorted": True, + } + }, + "subcategories": {}, + "children": ["Discussion"] + } + }, + "children": ["Chapter"] + } + ) + + def test_inline_without_always_cohort_inline_discussion_flag(self): + self.create_discussion("Chapter", "Discussion") + set_course_cohort_settings(course_key=self.course.id, is_cohorted=True, always_cohort_inline_discussions=False) + + self.assert_category_map_equals( + { + "entries": {}, + "subcategories": { + "Chapter": { + "entries": { + "Discussion": { + "id": "discussion1", + "sort_key": None, + "is_cohorted": False, + } + }, + "subcategories": {}, + "children": ["Discussion"] + } + }, + "children": ["Chapter"] + }, + cohorted_if_in_list=True + ) + + def test_get_unstarted_discussion_modules(self): + later = datetime.datetime(datetime.MAXYEAR, 1, 1, tzinfo=django_utc()) + + self.create_discussion("Chapter 1", "Discussion 1", start=later) + + self.assert_category_map_equals( + { + "entries": {}, + "subcategories": { + "Chapter 1": { + "entries": { + "Discussion 1": { + "id": "discussion1", + "sort_key": None, + "is_cohorted": False, + "start_date": later + } + }, + "subcategories": {}, + "children": ["Discussion 1"], + "start_date": later, + "sort_key": "Chapter 1" + } + }, + "children": ["Chapter 1"] + }, + cohorted_if_in_list=True, + exclude_unstarted=False + ) + def test_tree(self): self.create_discussion("Chapter 1", "Discussion 1") self.create_discussion("Chapter 1", "Discussion 2") @@ -352,11 +463,11 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): check_cohorted(False) # explicitly disabled cohorting - self.course.cohort_config = {"cohorted": False} + set_course_cohort_settings(course_key=self.course.id, is_cohorted=False) check_cohorted(False) # explicitly enabled cohorting - self.course.cohort_config = {"cohorted": True} + set_course_cohort_settings(course_key=self.course.id, is_cohorted=True) check_cohorted(True) def test_tree_with_duplicate_targets(self): @@ -381,8 +492,8 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): self.assertEqual(set(subsection1["entries"].keys()), subsection1_discussions) def test_start_date_filter(self): - now = datetime.now() - later = datetime.max + now = datetime.datetime.now() + later = datetime.datetime.max self.create_discussion("Chapter 1", "Discussion 1", start=now) self.create_discussion("Chapter 1", "Discussion 2 обсуждение", start=later) self.create_discussion("Chapter 2", "Discussion", start=now) @@ -420,6 +531,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): "children": ["Chapter 1", "Chapter 2"] } ) + self.maxDiff = None def test_sort_inline_explicit(self): self.create_discussion("Chapter", "Discussion 1", sort_key="D") diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 0a139850fa..d502a4c128 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -19,8 +19,9 @@ from django_comment_client.permissions import check_permissions_by_view, cached_ from edxmako import lookup_template from courseware.access import has_access -from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_id, get_cohort_id, is_commentable_cohorted, \ - is_course_cohorted +from openedx.core.djangoapps.course_groups.cohorts import ( + get_course_cohort_settings, get_cohort_by_id, get_cohort_id, is_commentable_cohorted, is_course_cohorted +) from openedx.core.djangoapps.course_groups.models import CourseUserGroup @@ -60,8 +61,7 @@ def has_forum_access(uname, course_id, rolename): return role.users.filter(username=uname).exists() -# pylint: disable=invalid-name -def get_accessible_discussion_modules(course, user): +def get_accessible_discussion_modules(course, user, include_all=False): # pylint: disable=invalid-name """ Return a list of all valid discussion modules in this course that are accessible to the given user. @@ -70,14 +70,14 @@ def get_accessible_discussion_modules(course, user): def has_required_keys(module): for key in ('discussion_id', 'discussion_category', 'discussion_target'): - if getattr(module, key) is None: + if getattr(module, key, None) is None: log.warning("Required key '%s' not in discussion %s, leaving out of category map" % (key, module.location)) return False return True return [ module for module in all_modules - if has_required_keys(module) and has_access(user, 'load', module, course.id) + if has_required_keys(module) and (include_all or has_access(user, 'load', module, course.id)) ] @@ -145,24 +145,63 @@ def _sort_map_entries(category_map, sort_alpha): category_map["children"] = [x[0] for x in sorted(things, key=lambda x: x[1]["sort_key"])] -def get_discussion_category_map(course, user): +def get_discussion_category_map(course, user, cohorted_if_in_list=False, exclude_unstarted=True): """ Transform the list of this course's discussion modules into a recursive dictionary structure. This is used to render the discussion category map in the discussion tab sidebar for a given user. + + Args: + course: Course for which to get the ids. + user: User to check for access. + cohorted_if_in_list (bool): If True, inline topics are marked is_cohorted only if they are + in course_cohort_settings.discussion_topics. + + Example: + >>> example = { + >>> "entries": { + >>> "General": { + >>> "sort_key": "General", + >>> "is_cohorted": True, + >>> "id": "i4x-edx-eiorguegnru-course-foobarbaz" + >>> } + >>> }, + >>> "children": ["General", "Getting Started"], + >>> "subcategories": { + >>> "Getting Started": { + >>> "subcategories": {}, + >>> "children": [ + >>> "Working with Videos", + >>> "Videos on edX" + >>> ], + >>> "entries": { + >>> "Working with Videos": { + >>> "sort_key": None, + >>> "is_cohorted": False, + >>> "id": "d9f970a42067413cbb633f81cfb12604" + >>> }, + >>> "Videos on edX": { + >>> "sort_key": None, + >>> "is_cohorted": False, + >>> "id": "98d8feb5971041a085512ae22b398613" + >>> } + >>> } + >>> } + >>> } + >>> } + """ unexpanded_category_map = defaultdict(list) modules = get_accessible_discussion_modules(course, user) - is_course_cohorted = course.is_cohorted - cohorted_discussion_ids = course.cohorted_discussions + course_cohort_settings = get_course_cohort_settings(course.id) for module in modules: id = module.discussion_id title = module.discussion_target sort_key = module.sort_key category = " / ".join([x.strip() for x in module.discussion_category.split("/")]) - #Handle case where module.start is None + # Handle case where module.start is None entry_start_date = module.start if module.start else datetime.max.replace(tzinfo=pytz.UTC) unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": entry_start_date}) @@ -198,8 +237,17 @@ def get_discussion_category_map(course, user): if node[level]["start_date"] > category_start_date: node[level]["start_date"] = category_start_date + always_cohort_inline_discussions = ( # pylint: disable=invalid-name + not cohorted_if_in_list and course_cohort_settings.always_cohort_inline_discussions + ) dupe_counters = defaultdict(lambda: 0) # counts the number of times we see each title for entry in entries: + is_entry_cohorted = ( + course_cohort_settings.is_cohorted and ( + always_cohort_inline_discussions or entry["id"] in course_cohort_settings.cohorted_discussions + ) + ) + title = entry["title"] if node[level]["entries"][title]: # If we've already seen this title, append an incrementing number to disambiguate @@ -209,29 +257,38 @@ def get_discussion_category_map(course, user): node[level]["entries"][title] = {"id": entry["id"], "sort_key": entry["sort_key"], "start_date": entry["start_date"], - "is_cohorted": is_course_cohorted} + "is_cohorted": is_entry_cohorted} # TODO. BUG! : course location is not unique across multiple course runs! # (I think Kevin already noticed this) Need to send course_id with requests, store it # in the backend. for topic, entry in course.discussion_topics.items(): - category_map['entries'][topic] = {"id": entry["id"], - "sort_key": entry.get("sort_key", topic), - "start_date": datetime.now(UTC()), - "is_cohorted": is_course_cohorted and entry["id"] in cohorted_discussion_ids} + category_map['entries'][topic] = { + "id": entry["id"], + "sort_key": entry.get("sort_key", topic), + "start_date": datetime.now(UTC()), + "is_cohorted": (course_cohort_settings.is_cohorted and + entry["id"] in course_cohort_settings.cohorted_discussions) + } _sort_map_entries(category_map, course.discussion_sort_alpha) - return _filter_unstarted_categories(category_map) + return _filter_unstarted_categories(category_map) if exclude_unstarted else category_map -def get_discussion_categories_ids(course, user): +def get_discussion_categories_ids(course, user, include_all=False): """ Returns a list of available ids of categories for the course that are accessible to the given user. + + Args: + course: Course for which to get the ids. + user: User to check for access. + include_all (bool): If True, return all ids. Used by configuration views. + """ accessible_discussion_ids = [ - module.discussion_id for module in get_accessible_discussion_modules(course, user) + module.discussion_id for module in get_accessible_discussion_modules(course, user, include_all=include_all) ] return course.top_level_discussion_topic_ids + accessible_discussion_ids @@ -402,7 +459,7 @@ def add_courseware_context(content_list, course, user, id_map=None): content.update({"courseware_url": url, "courseware_title": title}) -def prepare_content(content, course_key, is_staff=False): +def prepare_content(content, course_key, is_staff=False, course_is_cohorted=None): """ This function is used to pre-process thread and comment models in various ways before adding them to the HTTP response. This includes fixing empty @@ -411,6 +468,12 @@ def prepare_content(content, course_key, is_staff=False): @TODO: not all response pre-processing steps are currently integrated into this function. + + Arguments: + content (dict): A thread or comment. + course_key (CourseKey): The course key of the course. + is_staff (bool): Whether the user is a staff member. + course_is_cohorted (bool): Whether the course is cohorted. """ fields = [ 'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers', @@ -450,14 +513,18 @@ def prepare_content(content, course_key, is_staff=False): else: del endorsement["user_id"] + if course_is_cohorted is None: + course_is_cohorted = is_course_cohorted(course_key) + for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]: if child_content_key in content: children = [ - prepare_content(child, course_key, is_staff) for child in content[child_content_key] + prepare_content(child, course_key, is_staff, course_is_cohorted=course_is_cohorted) + for child in content[child_content_key] ] content[child_content_key] = children - if is_course_cohorted(course_key): + if course_is_cohorted: # Augment the specified thread info to include the group name if a group id is present. if content.get('group_id') is not None: content['group_name'] = get_cohort_by_id(course_key, content.get('group_id')).name diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 24eeac62c1..77624cfdd8 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -58,6 +58,8 @@ from instructor.views.api import generate_unique_password from instructor.views.api import _split_input_list, common_exceptions_400 from instructor_task.api_helper import AlreadyRunningError +from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings + from .test_tools import msk_from_problem_urlname from ..views.tools import get_extended_due @@ -2002,8 +2004,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa cohorted, and does not when the course is not cohorted. """ url = reverse('get_students_features', kwargs={'course_id': unicode(self.course.id)}) - self.course.cohort_config = {'cohorted': is_cohorted} - self.store.update_item(self.course, self.instructor.id) + set_course_cohort_settings(self.course.id, is_cohorted=is_cohorted) response = self.client.get(url, {}) res_json = json.loads(response.content) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index b33abce283..8d5247c1c3 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -104,6 +104,7 @@ from .tools import ( from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys import InvalidKeyError +from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted log = logging.getLogger(__name__) @@ -1002,7 +1003,7 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red 'goals': _('Goals'), } - if course.is_cohorted: + if is_course_cohorted(course.id): # Translators: 'Cohort' refers to a group of students within a course. query_features.append('cohort') query_features_names['cohort'] = _('Cohort') diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 512283db94..5b44f8ace2 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -65,9 +65,7 @@ def instructor_dashboard_2(request, course_id): 'finance_admin': CourseFinanceAdminRole(course_key).has_user(request.user), 'sales_admin': CourseSalesAdminRole(course_key).has_user(request.user), 'staff': has_access(request.user, 'staff', course), - 'forum_admin': has_forum_access( - request.user, course_key, FORUM_ROLE_ADMINISTRATOR - ), + 'forum_admin': has_forum_access(request.user, course_key, FORUM_ROLE_ADMINISTRATOR), } if not access['staff']: @@ -76,6 +74,7 @@ def instructor_dashboard_2(request, course_id): sections = [ _section_course_info(course, access), _section_membership(course, access), + _section_cohort_management(course, access), _section_student_admin(course, access), _section_data_download(course, access), _section_analytics(course, access), @@ -330,9 +329,24 @@ def _section_membership(course, access): 'modify_access_url': reverse('modify_access', kwargs={'course_id': unicode(course_key)}), 'list_forum_members_url': reverse('list_forum_members', kwargs={'course_id': unicode(course_key)}), 'update_forum_role_membership_url': reverse('update_forum_role_membership', kwargs={'course_id': unicode(course_key)}), - 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_key_string': unicode(course_key)}), - 'advanced_settings_url': get_studio_url(course, 'settings/advanced'), + } + return section_data + + +def _section_cohort_management(course, access): + """ Provide data for the corresponding cohort management section """ + course_key = course.id + section_data = { + 'section_key': 'cohort_management', + 'section_display_name': _('Cohorts'), + 'access': access, + 'course_cohort_settings_url': reverse( + 'course_cohort_settings', + kwargs={'course_key_string': unicode(course_key)} + ), + 'cohorts_url': reverse('cohorts', kwargs={'course_key_string': unicode(course_key)}), 'upload_cohorts_csv_url': reverse('add_users_to_cohorts', kwargs={'course_id': unicode(course_key)}), + 'discussion_topics_url': reverse('cohort_discussion_topics', kwargs={'course_key_string': unicode(course_key)}), } return section_data diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 09ecfe05d3..4f208cb343 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -56,6 +56,8 @@ from django.utils.translation import ugettext as _ from microsite_configuration import microsite from opaque_keys.edx.locations import i4xEncoder +from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted + log = logging.getLogger(__name__) @@ -451,6 +453,7 @@ def instructor_dashboard(request, course_id): context = { 'course': course, + 'course_is_cohorted': is_course_cohorted(course.id), 'staff_access': True, 'admin_access': request.user.is_staff, 'instructor_access': instructor_access, diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index de89f61330..92087df572 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -34,7 +34,7 @@ from lms.djangoapps.lms_xblock.runtime import LmsPartitionService from openedx.core.djangoapps.course_groups.cohorts import get_cohort from openedx.core.djangoapps.course_groups.models import CourseUserGroup from opaque_keys.edx.keys import UsageKey -from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort +from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted from student.models import CourseEnrollment @@ -578,7 +578,8 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, TASK_LOG.info(u'%s, Task type: %s, Starting task execution', task_info_string, action_name) course = get_course_by_id(course_id) - cohorts_header = ['Cohort Name'] if course.is_cohorted else [] + course_is_cohorted = is_course_cohorted(course.id) + cohorts_header = ['Cohort Name'] if course_is_cohorted else [] experiment_partitions = get_split_user_partitions(course.user_partitions) group_configs_header = [u'Experiment Group ({})'.format(partition.name) for partition in experiment_partitions] @@ -632,7 +633,7 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, } cohorts_group_name = [] - if course.is_cohorted: + if course_is_cohorted: group = get_cohort(student, course_id, assign=False) cohorts_group_name.append(group.name if group else '') diff --git a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee index fa7c0d353f..3334bd8fb8 100644 --- a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee @@ -176,6 +176,9 @@ setup_instructor_dashboard_sections = (idash_content) -> , constructor: window.InstructorDashboard.sections.Metrics $element: idash_content.find ".#{CSS_IDASH_SECTION}#metrics" + , + constructor: window.InstructorDashboard.sections.CohortManagement + $element: idash_content.find ".#{CSS_IDASH_SECTION}#cohort_management" ] sections_to_initialize.map ({constructor, $element}) -> diff --git a/lms/static/js/edxnotes/views/toggle_notes_factory.js b/lms/static/js/edxnotes/views/toggle_notes_factory.js index 7a37788dd2..8fdc5fc071 100644 --- a/lms/static/js/edxnotes/views/toggle_notes_factory.js +++ b/lms/static/js/edxnotes/views/toggle_notes_factory.js @@ -2,7 +2,7 @@ 'use strict'; define([ 'jquery', 'underscore', 'backbone', 'gettext', - 'annotator_1.2.9', 'js/edxnotes/views/visibility_decorator' + 'annotator_1.2.9', 'js/edxnotes/views/visibility_decorator', 'js/utils/animation' ], function($, _, Backbone, gettext, Annotator, EdxnotesVisibilityDecorator) { var ToggleNotesView = Backbone.View.extend({ events: { @@ -31,7 +31,7 @@ define([ toggleHandler: function (event) { event.preventDefault(); this.visibility = !this.visibility; - this.showActionMessage(); + AnimationUtil.triggerAnimation(this.actionToggleMessage); this.toggleNotes(this.visibility); }, @@ -51,13 +51,6 @@ define([ this.sendRequest(); }, - showActionMessage: function () { - // The following lines are necessary to re-trigger the CSS animation on span.action-toggle-message - this.actionToggleMessage.removeClass('is-fleeting'); - this.actionToggleMessage.offset().width = this.actionToggleMessage.offset().width; - this.actionToggleMessage.addClass('is-fleeting'); - }, - enableNotes: function () { _.each($('.edx-notes-wrapper'), EdxnotesVisibilityDecorator.enableNote); this.actionLink.addClass('is-active'); diff --git a/lms/static/js/groups/models/cohort_discussions.js b/lms/static/js/groups/models/cohort_discussions.js new file mode 100644 index 0000000000..90af5a9207 --- /dev/null +++ b/lms/static/js/groups/models/cohort_discussions.js @@ -0,0 +1,14 @@ +var edx = edx || {}; + +(function(Backbone) { + 'use strict'; + + edx.groups = edx.groups || {}; + + edx.groups.DiscussionTopicsSettingsModel = Backbone.Model.extend({ + defaults: { + course_wide_discussions: {}, + inline_discussions: {} + } + }); +}).call(this, Backbone); diff --git a/lms/static/js/groups/models/course_cohort_settings.js b/lms/static/js/groups/models/course_cohort_settings.js new file mode 100644 index 0000000000..74be792614 --- /dev/null +++ b/lms/static/js/groups/models/course_cohort_settings.js @@ -0,0 +1,17 @@ +var edx = edx || {}; + +(function(Backbone) { + 'use strict'; + + edx.groups = edx.groups || {}; + + edx.groups.CourseCohortSettingsModel = Backbone.Model.extend({ + idAttribute: 'id', + defaults: { + is_cohorted: false, + cohorted_inline_discussions: [], + cohorted_course_wide_discussions:[], + always_cohort_inline_discussions: true + } + }); +}).call(this, Backbone); diff --git a/lms/static/js/groups/views/cohort_discussions.js b/lms/static/js/groups/views/cohort_discussions.js new file mode 100644 index 0000000000..b20c37d1f8 --- /dev/null +++ b/lms/static/js/groups/views/cohort_discussions.js @@ -0,0 +1,99 @@ +var edx = edx || {}; + +(function ($, _, Backbone, gettext, interpolate_text, NotificationModel, NotificationView) { + 'use strict'; + + edx.groups = edx.groups || {}; + + edx.groups.CohortDiscussionConfigurationView = Backbone.View.extend({ + + /** + * Add/Remove the disabled attribute on given element. + * @param {object} $element - The element to disable/enable. + * @param {bool} disable - The flag to add/remove 'disabled' attribute. + */ + setDisabled: function($element, disable) { + $element.prop('disabled', disable ? 'disabled' : false); + }, + + /** + * Returns the cohorted discussions list. + * @param {string} selector - To select the discussion elements whose ids to return. + * @returns {Array} - Cohorted discussions. + */ + getCohortedDiscussions: function(selector) { + var self=this, + cohortedDiscussions = []; + + _.each(self.$(selector), function (topic) { + cohortedDiscussions.push($(topic).data('id')) + }); + return cohortedDiscussions; + }, + + /** + * Save the cohortSettings' changed attributes to the server via PATCH method. + * It shows the error message(s) if any. + * @param {object} $element - Messages would be shown before this element. + * @param {object} fieldData - Data to update on the server. + */ + saveForm: function ($element, fieldData) { + var self = this, + cohortSettingsModel = this.cohortSettings, + saveOperation = $.Deferred(), + showErrorMessage; + + showErrorMessage = function (message, $element) { + self.showMessage(message, $element, 'error'); + }; + this.removeNotification(); + + cohortSettingsModel.save( + fieldData, {patch: true, wait: true} + ).done(function () { + saveOperation.resolve(); + }).fail(function (result) { + var errorMessage = null; + try { + var jsonResponse = JSON.parse(result.responseText); + errorMessage = jsonResponse.error; + } catch (e) { + // Ignore the exception and show the default error message instead. + } + if (!errorMessage) { + errorMessage = gettext("We've encountered an error. Refresh your browser and then try again."); + } + showErrorMessage(errorMessage, $element); + saveOperation.reject(); + }); + return saveOperation.promise(); + }, + + /** + * Shows the notification messages before given element using the NotificationModel. + * @param {string} message - Text message to show. + * @param {object} $element - Message would be shown before this element. + * @param {string} type - Type of message to show e.g. confirmation or error. + */ + showMessage: function (message, $element, type) { + var model = new NotificationModel({type: type || 'confirmation', title: message}); + this.removeNotification(); + this.notification = new NotificationView({ + model: model + }); + $element.before(this.notification.$el); + this.notification.render(); + }, + + /** + *Removes the notification messages. + */ + removeNotification: function () { + if (this.notification) { + this.notification.remove(); + } + } + + }); +}).call(this, $, _, Backbone, gettext, interpolate_text, NotificationModel, NotificationView +); diff --git a/lms/static/js/groups/views/cohort_discussions_course_wide.js b/lms/static/js/groups/views/cohort_discussions_course_wide.js new file mode 100644 index 0000000000..97a06fea84 --- /dev/null +++ b/lms/static/js/groups/views/cohort_discussions_course_wide.js @@ -0,0 +1,83 @@ +var edx = edx || {}; + +(function ($, _, Backbone, gettext, interpolate_text, CohortDiscussionConfigurationView) { + 'use strict'; + + edx.groups = edx.groups || {}; + + edx.groups.CourseWideDiscussionsView = CohortDiscussionConfigurationView.extend({ + events: { + 'change .check-discussion-subcategory-course-wide': 'discussionCategoryStateChanged', + 'click .cohort-course-wide-discussions-form .action-save': 'saveCourseWideDiscussionsForm' + }, + + initialize: function (options) { + this.template = _.template($('#cohort-discussions-course-wide-tpl').text()); + this.cohortSettings = options.cohortSettings; + }, + + render: function () { + this.$('.cohort-course-wide-discussions-nav').html(this.template({ + courseWideTopics: this.getCourseWideDiscussionsHtml( + this.model.get('course_wide_discussions') + ) + })); + this.setDisabled(this.$('.cohort-course-wide-discussions-form .action-save'), true); + }, + + /** + * Returns the html list for course-wide discussion topics. + * @param {object} courseWideDiscussions - course-wide discussions object from server. + * @returns {Array} - HTML list for course-wide discussion topics. + */ + getCourseWideDiscussionsHtml: function (courseWideDiscussions) { + var subCategoryTemplate = _.template($('#cohort-discussions-subcategory-tpl').html()), + entries = courseWideDiscussions.entries, + children = courseWideDiscussions.children; + + return _.map(children, function (name) { + var entry = entries[name]; + return subCategoryTemplate({ + name: name, + id: entry.id, + is_cohorted: entry.is_cohorted, + type: 'course-wide' + }); + }).join(''); + }, + + /** + * Enables the save button for course-wide discussions. + */ + discussionCategoryStateChanged: function(event) { + event.preventDefault(); + this.setDisabled(this.$('.cohort-course-wide-discussions-form .action-save'), false); + }, + + /** + * Sends the cohorted_course_wide_discussions to the server and renders the view. + */ + saveCourseWideDiscussionsForm: function (event) { + event.preventDefault(); + + var self = this, + courseWideCohortedDiscussions = self.getCohortedDiscussions( + '.check-discussion-subcategory-course-wide:checked' + ), + fieldData = { cohorted_course_wide_discussions: courseWideCohortedDiscussions }; + + self.saveForm(self.$('.course-wide-discussion-topics'),fieldData) + .done(function () { + self.model.fetch() + .done(function () { + self.render(); + self.showMessage(gettext('Your changes have been saved.'), self.$('.course-wide-discussion-topics')); + }).fail(function() { + var errorMessage = gettext("We've encountered an error. Refresh your browser and then try again."); + self.showMessage(errorMessage, self.$('.course-wide-discussion-topics'), 'error') + }); + }); + } + + }); +}).call(this, $, _, Backbone, gettext, interpolate_text, edx.groups.CohortDiscussionConfigurationView); diff --git a/lms/static/js/groups/views/cohort_discussions_inline.js b/lms/static/js/groups/views/cohort_discussions_inline.js new file mode 100644 index 0000000000..036b2dafc6 --- /dev/null +++ b/lms/static/js/groups/views/cohort_discussions_inline.js @@ -0,0 +1,145 @@ +var edx = edx || {}; + +(function ($, _, Backbone, gettext, interpolate_text, CohortDiscussionConfigurationView) { + 'use strict'; + + edx.groups = edx.groups || {}; + + edx.groups.InlineDiscussionsView = CohortDiscussionConfigurationView.extend({ + events: { + 'change .check-discussion-category': 'setSaveButton', + 'change .check-discussion-subcategory-inline': 'setSaveButton', + 'click .cohort-inline-discussions-form .action-save': 'saveInlineDiscussionsForm', + 'change .check-all-inline-discussions': 'setAllInlineDiscussions', + 'change .check-cohort-inline-discussions': 'setSomeInlineDiscussions' + }, + + initialize: function (options) { + this.template = _.template($('#cohort-discussions-inline-tpl').text()); + this.cohortSettings = options.cohortSettings; + }, + + render: function () { + var alwaysCohortInlineDiscussions = this.cohortSettings.get('always_cohort_inline_discussions'); + + this.$('.cohort-inline-discussions-nav').html(this.template({ + inlineDiscussionTopics: this.getInlineDiscussionsHtml(this.model.get('inline_discussions')), + alwaysCohortInlineDiscussions:alwaysCohortInlineDiscussions + })); + + // Provides the semantics for a nested list of tri-state checkboxes. + // When attached to a jQuery element it listens for change events to + // input[type=checkbox] elements, and updates the checked and indeterminate + // based on the checked values of any checkboxes in child elements of the DOM. + this.$('ul.inline-topics').qubit(); + + this.setElementsEnabled(alwaysCohortInlineDiscussions, true); + }, + + /** + * Generate html list for inline discussion topics. + * @params {object} inlineDiscussions - inline discussions object from server. + * @returns {Array} - HTML for inline discussion topics. + */ + getInlineDiscussionsHtml: function (inlineDiscussions) { + var categoryTemplate = _.template($('#cohort-discussions-category-tpl').html()), + entryTemplate = _.template($('#cohort-discussions-subcategory-tpl').html()), + isCategoryCohorted = false, + children = inlineDiscussions.children, + entries = inlineDiscussions.entries, + subcategories = inlineDiscussions.subcategories; + + return _.map(children, function (name) { + var html = '', entry; + if (entries && _.has(entries, name)) { + entry = entries[name]; + html = entryTemplate({ + name: name, + id: entry.id, + is_cohorted: entry.is_cohorted, + type: 'inline' + }); + } else { // subcategory + html = categoryTemplate({ + name: name, + entries: this.getInlineDiscussionsHtml(subcategories[name]), + isCategoryCohorted: isCategoryCohorted + }); + } + return html; + }, this).join(''); + }, + + /** + * Enable/Disable the inline discussion elements. + * + * Disables the category and sub-category checkboxes. + * Enables the save button. + */ + setAllInlineDiscussions: function(event) { + event.preventDefault(); + this.setElementsEnabled(($(event.currentTarget).prop('checked')), false); + }, + + /** + * Enables the inline discussion elements. + * + * Enables the category and sub-category checkboxes. + * Enables the save button. + */ + setSomeInlineDiscussions: function(event) { + event.preventDefault(); + this.setElementsEnabled(!($(event.currentTarget).prop('checked')), false); + }, + + /** + * Enable/Disable the inline discussion elements. + * + * Enable/Disable the category and sub-category checkboxes. + * Enable/Disable the save button. + * @param {bool} enable_checkboxes - The flag to enable/disable the checkboxes. + * @param {bool} enable_save_button - The flag to enable/disable the save button. + */ + setElementsEnabled: function(enable_checkboxes, enable_save_button) { + this.setDisabled(this.$('.check-discussion-category'), enable_checkboxes); + this.setDisabled(this.$('.check-discussion-subcategory-inline'), enable_checkboxes); + this.setDisabled(this.$('.cohort-inline-discussions-form .action-save'), enable_save_button); + }, + + /** + * Enables the save button for inline discussions. + */ + setSaveButton: function(event) { + this.setDisabled(this.$('.cohort-inline-discussions-form .action-save'), false); + }, + + /** + * Sends the cohorted_inline_discussions to the server and renders the view. + */ + saveInlineDiscussionsForm: function (event) { + event.preventDefault(); + + var self = this, + cohortedInlineDiscussions = self.getCohortedDiscussions( + '.check-discussion-subcategory-inline:checked' + ), + fieldData= { + cohorted_inline_discussions: cohortedInlineDiscussions, + always_cohort_inline_discussions: self.$('.check-all-inline-discussions').prop('checked') + }; + + self.saveForm(self.$('.inline-discussion-topics'), fieldData) + .done(function () { + self.model.fetch() + .done(function () { + self.render(); + self.showMessage(gettext('Your changes have been saved.'), self.$('.inline-discussion-topics')); + }).fail(function() { + var errorMessage = gettext("We've encountered an error. Refresh your browser and then try again."); + self.showMessage(errorMessage, self.$('.inline-discussion-topics'), 'error') + }); + }); + } + + }); +}).call(this, $, _, Backbone, gettext, interpolate_text, edx.groups.CohortDiscussionConfigurationView); diff --git a/lms/static/js/groups/views/cohort_editor.js b/lms/static/js/groups/views/cohort_editor.js index 630f8dec5f..8795c9baf5 100644 --- a/lms/static/js/groups/views/cohort_editor.js +++ b/lms/static/js/groups/views/cohort_editor.js @@ -6,14 +6,17 @@ var edx = edx || {}; edx.groups = edx.groups || {}; edx.groups.CohortEditorView = Backbone.View.extend({ + events : { 'click .wrapper-tabs .tab': 'selectTab', 'click .tab-content-settings .action-save': 'saveSettings', + 'click .tab-content-settings .action-cancel': 'cancelSettings', 'submit .cohort-management-group-add-form': 'addStudents' }, initialize: function(options) { this.template = _.template($('#cohort-editor-tpl').text()); + this.groupHeaderTemplate = _.template($('#cohort-group-header-tpl').text()); this.cohorts = options.cohorts; this.contentGroups = options.contentGroups; this.context = options.context; @@ -26,9 +29,9 @@ var edx = edx || {}; render: function() { this.$el.html(this.template({ - cohort: this.model, - studioAdvancedSettingsUrl: this.context.studioAdvancedSettingsUrl + cohort: this.model })); + this.renderGroupHeader(); this.cohortFormView = new CohortFormView({ model: this.model, contentGroups: this.contentGroups, @@ -39,6 +42,12 @@ var edx = edx || {}; return this; }, + renderGroupHeader: function() { + this.$('.cohort-management-group-header').html(this.groupHeaderTemplate({ + cohort: this.model + })); + }, + selectTab: function(event) { var tabElement = $(event.currentTarget), tabName = tabElement.data('tab'); @@ -53,13 +62,20 @@ var edx = edx || {}; saveSettings: function(event) { var cohortFormView = this.cohortFormView; + var self = this; event.preventDefault(); cohortFormView.saveForm() .done(function() { + self.renderGroupHeader(); cohortFormView.showMessage(gettext('Saved cohort')); }); }, + cancelSettings: function(event) { + event.preventDefault(); + this.render(); + }, + setCohort: function(cohort) { this.model = cohort; this.render(); diff --git a/lms/static/js/groups/views/cohort_form.js b/lms/static/js/groups/views/cohort_form.js index 01cbf7c85b..2eba90da49 100644 --- a/lms/static/js/groups/views/cohort_form.js +++ b/lms/static/js/groups/views/cohort_form.js @@ -35,12 +35,22 @@ var edx = edx || {}; render: function() { this.$el.html(this.template({ cohort: this.model, + isDefaultCohort: this.isDefault(this.model.get('name')), contentGroups: this.contentGroups, studioGroupConfigurationsUrl: this.context.studioGroupConfigurationsUrl })); return this; }, + isDefault: function(name) { + var cohorts = this.model.collection; + if (_.isUndefined(cohorts)) { + return false; + } + var randomModels = cohorts.where({assignment_type:'random'}); + return (randomModels.length === 1) && (randomModels[0].get('name') === name); + }, + onRadioButtonChange: function(event) { var target = $(event.currentTarget), groupsEnabled = target.val() === 'yes'; @@ -77,7 +87,11 @@ var edx = edx || {}; getUpdatedCohortName: function() { var cohortName = this.$('.cohort-name').val(); - return cohortName ? cohortName.trim() : this.model.get('name'); + return cohortName ? cohortName.trim() : ''; + }, + + getAssignmentType: function() { + return this.$('input[name="cohort-assignment-type"]:checked').val(); }, showMessage: function(message, type, details) { @@ -109,18 +123,21 @@ var edx = edx || {}; cohort = this.model, saveOperation = $.Deferred(), isUpdate = !_.isUndefined(this.model.id), - fieldData, selectedContentGroup, errorMessages, showErrorMessage; + fieldData, selectedContentGroup, selectedAssignmentType, errorMessages, showErrorMessage; showErrorMessage = function(message, details) { self.showMessage(message, 'error', details); }; this.removeNotification(); selectedContentGroup = this.getSelectedContentGroup(); + selectedAssignmentType = this.getAssignmentType(); fieldData = { name: this.getUpdatedCohortName(), group_id: selectedContentGroup ? selectedContentGroup.id : null, - user_partition_id: selectedContentGroup ? selectedContentGroup.get('user_partition_id') : null + user_partition_id: selectedContentGroup ? selectedContentGroup.get('user_partition_id') : null, + assignment_type: selectedAssignmentType }; errorMessages = this.validate(fieldData); + if (errorMessages.length > 0) { showErrorMessage( isUpdate ? gettext("The cohort cannot be saved") : gettext("The cohort cannot be added"), @@ -129,7 +146,7 @@ var edx = edx || {}; saveOperation.reject(); } else { cohort.save( - fieldData, {patch: isUpdate} + fieldData, {patch: isUpdate, wait: true} ).done(function(result) { cohort.id = result.id; self.render(); // re-render to remove any now invalid error messages diff --git a/lms/static/js/groups/views/cohorts.js b/lms/static/js/groups/views/cohorts.js index 4815eaa567..0d5714d901 100644 --- a/lms/static/js/groups/views/cohorts.js +++ b/lms/static/js/groups/views/cohorts.js @@ -1,7 +1,8 @@ var edx = edx || {}; (function($, _, Backbone, gettext, interpolate_text, CohortModel, CohortEditorView, CohortFormView, - NotificationModel, NotificationView, FileUploaderView) { + CourseCohortSettingsNotificationView, NotificationModel, NotificationView, FileUploaderView, + InlineDiscussionsView, CourseWideDiscussionsView) { 'use strict'; var hiddenClass = 'is-hidden', @@ -12,11 +13,13 @@ var edx = edx || {}; edx.groups.CohortsView = Backbone.View.extend({ events : { 'change .cohort-select': 'onCohortSelected', + 'change .cohorts-state': 'onCohortsEnabledChanged', 'click .action-create': 'showAddCohortForm', 'click .cohort-management-add-form .action-save': 'saveAddCohortForm', 'click .cohort-management-add-form .action-cancel': 'cancelAddCohortForm', 'click .link-cross-reference': 'showSection', - 'click .toggle-cohort-management-secondary': 'showCsvUpload' + 'click .toggle-cohort-management-secondary': 'showCsvUpload', + 'click .toggle-cohort-management-discussions': 'showDiscussionTopics' }, initialize: function(options) { @@ -26,19 +29,21 @@ var edx = edx || {}; this.selectorTemplate = _.template($('#cohort-selector-tpl').text()); this.context = options.context; this.contentGroups = options.contentGroups; + this.cohortSettings = options.cohortSettings; model.on('sync', this.onSync, this); - // Update cohort counts when the user clicks back on the membership tab + // Update cohort counts when the user clicks back on the cohort management tab // (for example, after uploading a csv file of cohort assignments and then // checking results on data download tab). - $(this.getSectionCss('membership')).click(function () { + $(this.getSectionCss('cohort_management')).click(function () { model.fetch(); }); }, render: function() { this.$el.html(this.template({ - cohorts: this.model.models + cohorts: this.model.models, + cohortsEnabled: this.cohortSettings.get('is_cohorted') })); this.onSync(); return this; @@ -51,6 +56,14 @@ var edx = edx || {}; })); }, + renderCourseCohortSettingsNotificationView: function() { + var cohortStateMessageNotificationView = new CourseCohortSettingsNotificationView({ + el: $('.cohort-state-message'), + cohortEnabled: this.getCohortsEnabled() + }); + cohortStateMessageNotificationView.render(); + }, + onSync: function(model, response, options) { var selectedCohort = this.lastSelectedCohortId && this.model.get(this.lastSelectedCohortId), hasCohorts = this.model.length > 0, @@ -98,6 +111,34 @@ var edx = edx || {}; this.showCohortEditor(selectedCohort); }, + onCohortsEnabledChanged: function(event) { + event.preventDefault(); + this.saveCohortSettings(); + }, + + saveCohortSettings: function() { + var self = this, + cohortSettings, + fieldData = {is_cohorted: this.getCohortsEnabled()}; + cohortSettings = this.cohortSettings; + cohortSettings.save( + fieldData, {patch: true, wait: true} + ).done(function() { + self.render(); + self.renderCourseCohortSettingsNotificationView(); + }).fail(function(result) { + self.showNotification({ + type: 'error', + title: gettext("We've encountered an error. Refresh your browser and then try again.")}, + self.$('.cohorts-state-section') + ); + }); + }, + + getCohortsEnabled: function() { + return this.$('.cohorts-state').prop('checked'); + }, + showCohortEditor: function(cohort) { this.removeNotification(); if (this.editor) { @@ -167,9 +208,11 @@ var edx = edx || {}; setCohortEditorVisibility: function(showEditor) { if (showEditor) { + this.$('.cohorts-state-section').removeClass(disabledClass).attr('aria-disabled', false); this.$('.cohort-management-group').removeClass(hiddenClass); this.$('.cohort-management-nav').removeClass(disabledClass).attr('aria-disabled', false); } else { + this.$('.cohorts-state-section').addClass(disabledClass).attr('aria-disabled', true); this.$('.cohort-management-group').addClass(hiddenClass); this.$('.cohort-management-nav').addClass(disabledClass).attr('aria-disabled', true); } @@ -236,10 +279,32 @@ var edx = edx || {}; this.$('#file-upload-form-file').focus(); } }, + showDiscussionTopics: function(event) { + event.preventDefault(); + + $(event.currentTarget).addClass(hiddenClass); + var cohortDiscussionsElement = this.$('.cohort-discussions-nav').removeClass(hiddenClass); + + if (!this.CourseWideDiscussionsView) { + this.CourseWideDiscussionsView = new CourseWideDiscussionsView({ + el: cohortDiscussionsElement, + model: this.context.discussionTopicsSettingsModel, + cohortSettings: this.cohortSettings + }).render(); + } + if(!this.InlineDiscussionsView) { + this.InlineDiscussionsView = new InlineDiscussionsView({ + el: cohortDiscussionsElement, + model: this.context.discussionTopicsSettingsModel, + cohortSettings: this.cohortSettings + }).render(); + } + }, getSectionCss: function (section) { return ".instructor-nav .nav-item a[data-section='" + section + "']"; } }); }).call(this, $, _, Backbone, gettext, interpolate_text, edx.groups.CohortModel, edx.groups.CohortEditorView, - edx.groups.CohortFormView, NotificationModel, NotificationView, FileUploaderView); + edx.groups.CohortFormView, edx.groups.CourseCohortSettingsNotificationView, NotificationModel, NotificationView, + FileUploaderView, edx.groups.InlineDiscussionsView, edx.groups.CourseWideDiscussionsView); diff --git a/lms/static/js/groups/views/cohorts_dashboard_factory.js b/lms/static/js/groups/views/cohorts_dashboard_factory.js new file mode 100644 index 0000000000..3ddf229220 --- /dev/null +++ b/lms/static/js/groups/views/cohorts_dashboard_factory.js @@ -0,0 +1,41 @@ +;(function (define, undefined) { + 'use strict'; + define(['jquery', 'js/groups/views/cohorts', 'js/groups/collections/cohort', 'js/groups/models/course_cohort_settings', + 'js/groups/models/cohort_discussions'], + function($) { + + return function(contentGroups, studioGroupConfigurationsUrl) { + + var cohorts = new edx.groups.CohortCollection(), + courseCohortSettings = new edx.groups.CourseCohortSettingsModel(), + discussionTopicsSettings = new edx.groups.DiscussionTopicsSettingsModel(); + + var cohortManagementElement = $('.cohort-management'); + + cohorts.url = cohortManagementElement.data('cohorts_url'); + courseCohortSettings.url = cohortManagementElement.data('course_cohort_settings_url'); + discussionTopicsSettings.url = cohortManagementElement.data('discussion-topics-url'); + + var cohortsView = new edx.groups.CohortsView({ + el: cohortManagementElement, + model: cohorts, + contentGroups: contentGroups, + cohortSettings: courseCohortSettings, + context: { + discussionTopicsSettingsModel: discussionTopicsSettings, + uploadCohortsCsvUrl: cohortManagementElement.data('upload_cohorts_csv_url'), + studioGroupConfigurationsUrl: studioGroupConfigurationsUrl + } + }); + + cohorts.fetch().done(function() { + courseCohortSettings.fetch().done(function() { + discussionTopicsSettings.fetch().done(function() { + cohortsView.render(); + }); + }); + }); + }; + }); +}).call(this, define || RequireJS.define); + diff --git a/lms/static/js/groups/views/course_cohort_settings_notification.js b/lms/static/js/groups/views/course_cohort_settings_notification.js new file mode 100644 index 0000000000..e675e3440c --- /dev/null +++ b/lms/static/js/groups/views/course_cohort_settings_notification.js @@ -0,0 +1,31 @@ +var edx = edx || {}; + +(function($, _, Backbone, gettext) { + 'use strict'; + + edx.groups = edx.groups || {}; + + edx.groups.CourseCohortSettingsNotificationView = Backbone.View.extend({ + initialize: function(options) { + this.template = _.template($('#cohort-state-tpl').text()); + this.cohortEnabled = options.cohortEnabled; + }, + + render: function() { + this.$el.html(this.template({})); + this.showCohortStateMessage(); + return this; + }, + + showCohortStateMessage: function () { + var actionToggleMessage = this.$('.action-toggle-message'); + + AnimationUtil.triggerAnimation(actionToggleMessage); + if (this.cohortEnabled) { + actionToggleMessage.text(gettext('Cohorts Enabled')); + } else { + actionToggleMessage.text(gettext('Cohorts Disabled')); + } + } + }); +}).call(this, $, _, Backbone, gettext); diff --git a/lms/static/js/instructor_dashboard/cohort_management.js b/lms/static/js/instructor_dashboard/cohort_management.js new file mode 100644 index 0000000000..abe725f21a --- /dev/null +++ b/lms/static/js/instructor_dashboard/cohort_management.js @@ -0,0 +1,19 @@ +(function() { + var CohortManagement; + + CohortManagement = (function() { + + function CohortManagement($section) { + this.$section = $section; + this.$section.data('wrapper', this); + } + + CohortManagement.prototype.onClickTitle = function() {}; + + return CohortManagement; + + })(); + + window.InstructorDashboard.sections.CohortManagement = CohortManagement; + +}).call(this); diff --git a/lms/static/js/spec/groups/views/cohorts_spec.js b/lms/static/js/spec/groups/views/cohorts_spec.js index a2356b5f20..175b43f369 100644 --- a/lms/static/js/spec/groups/views/cohorts_spec.js +++ b/lms/static/js/spec/groups/views/cohorts_spec.js @@ -1,25 +1,45 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', - 'js/groups/views/cohorts', 'js/groups/collections/cohort', 'js/groups/models/content_group'], - function (Backbone, $, AjaxHelpers, TemplateHelpers, CohortsView, CohortCollection, ContentGroupModel) { + 'js/groups/views/cohorts', 'js/groups/collections/cohort', 'js/groups/models/content_group', + 'js/groups/models/course_cohort_settings', 'js/utils/animation', 'js/vendor/jquery.qubit', + 'js/groups/views/course_cohort_settings_notification', 'js/groups/models/cohort_discussions', + 'js/groups/views/cohort_discussions', 'js/groups/views/cohort_discussions_course_wide', + 'js/groups/views/cohort_discussions_inline' + ], + function (Backbone, $, AjaxHelpers, TemplateHelpers, CohortsView, CohortCollection, ContentGroupModel, + CourseCohortSettingsModel, AnimationUtil, Qubit, CourseCohortSettingsNotificationView, DiscussionTopicsSettingsModel, + CohortDiscussionsView, CohortCourseWideDiscussionsView, CohortInlineDiscussionsView) { 'use strict'; describe("Cohorts View", function () { var catLoversInitialCount = 123, dogLoversInitialCount = 456, unknownUserMessage, - createMockCohort, createMockCohorts, createMockContentGroups, createCohortsView, cohortsView, - requests, respondToRefresh, verifyMessage, verifyNoMessage, verifyDetailedMessage, verifyHeader, - expectCohortAddRequest, getAddModal, selectContentGroup, clearContentGroup, saveFormAndExpectErrors, - MOCK_COHORTED_USER_PARTITION_ID, MOCK_UPLOAD_COHORTS_CSV_URL, MOCK_STUDIO_ADVANCED_SETTINGS_URL, - MOCK_STUDIO_GROUP_CONFIGURATIONS_URL; + createMockCohort, createMockCohorts, createMockContentGroups, createMockCohortSettingsJson, + createCohortsView, cohortsView, requests, respondToRefresh, verifyMessage, verifyNoMessage, + verifyDetailedMessage, verifyHeader, expectCohortAddRequest, getAddModal, selectContentGroup, + clearContentGroup, saveFormAndExpectErrors, createMockCohortSettings, MOCK_COHORTED_USER_PARTITION_ID, + MOCK_UPLOAD_COHORTS_CSV_URL, MOCK_STUDIO_ADVANCED_SETTINGS_URL, MOCK_STUDIO_GROUP_CONFIGURATIONS_URL, + MOCK_MANUAL_ASSIGNMENT, MOCK_RANDOM_ASSIGNMENT, createMockCohortDiscussionsJson, + createMockCohortDiscussions, showAndAssertDiscussionTopics; + // Selectors + var discussionsToggle ='.toggle-cohort-management-discussions', + inlineDiscussionsFormCss = '.cohort-inline-discussions-form', + courseWideDiscussionsFormCss = '.cohort-course-wide-discussions-form', + courseWideDiscussionsSaveButtonCss = '.cohort-course-wide-discussions-form .action-save', + inlineDiscussionsSaveButtonCss = '.cohort-inline-discussions-form .action-save', + inlineDiscussionsForm, courseWideDiscussionsForm; + + MOCK_MANUAL_ASSIGNMENT = 'manual'; + MOCK_RANDOM_ASSIGNMENT = 'random'; MOCK_COHORTED_USER_PARTITION_ID = 0; MOCK_UPLOAD_COHORTS_CSV_URL = 'http://upload-csv-file-url/'; MOCK_STUDIO_ADVANCED_SETTINGS_URL = 'http://studio/settings/advanced'; MOCK_STUDIO_GROUP_CONFIGURATIONS_URL = 'http://studio/group_configurations'; - createMockCohort = function (name, id, userCount, groupId, userPartitionId) { + createMockCohort = function (name, id, userCount, groupId, userPartitionId, assignmentType) { return { id: id !== undefined ? id : 1, name: name, + assignment_type: assignmentType || MOCK_MANUAL_ASSIGNMENT, user_count: userCount !== undefined ? userCount : 0, group_id: groupId, user_partition_id: userPartitionId @@ -46,23 +66,95 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe ]; }; + createMockCohortSettingsJson = function (isCohorted, cohortedInlineDiscussions, cohortedCourseWideDiscussions, alwaysCohortInlineDiscussions) { + return { + id: 0, + is_cohorted: isCohorted || false, + cohorted_inline_discussions: cohortedInlineDiscussions || [], + cohorted_course_wide_discussions: cohortedCourseWideDiscussions || [], + always_cohort_inline_discussions: alwaysCohortInlineDiscussions || true + }; + }; + + createMockCohortSettings = function (isCohorted, cohortedInlineDiscussions, cohortedCourseWideDiscussions, alwaysCohortInlineDiscussions) { + return new CourseCohortSettingsModel( + createMockCohortSettingsJson(isCohorted, cohortedInlineDiscussions, cohortedCourseWideDiscussions, alwaysCohortInlineDiscussions) + ); + }; + + createMockCohortDiscussionsJson = function (allCohorted) { + return { + course_wide_discussions: { + children: ['Topic_C_1', 'Topic_C_2'], + entries: { + Topic_C_1: { + sort_key: null, + is_cohorted: true, + id: 'Topic_C_1' + }, + Topic_C_2: { + sort_key: null, + is_cohorted: false, + id: 'Topic_C_2' + } + } + }, + inline_discussions: { + subcategories: { + Topic_I_1: { + subcategories: {}, + children: ['Inline_Discussion_1', 'Inline_Discussion_2'], + entries: { + Inline_Discussion_1: { + sort_key: null, + is_cohorted: true, + id: 'Inline_Discussion_1' + }, + Inline_Discussion_2: { + sort_key: null, + is_cohorted: allCohorted || false, + id: 'Inline_Discussion_2' + } + } + } + }, + children: ['Topic_I_1'] + } + }; + }; + + createMockCohortDiscussions = function (allCohorted) { + return new DiscussionTopicsSettingsModel( + createMockCohortDiscussionsJson(allCohorted) + ); + }; + createCohortsView = function (test, options) { - var cohortsJson, cohorts, contentGroups; + var cohortsJson, cohorts, contentGroups, cohortSettings, cohortDiscussions; options = options || {}; cohortsJson = options.cohorts ? {cohorts: options.cohorts} : createMockCohorts(); cohorts = new CohortCollection(cohortsJson, {parse: true}); contentGroups = options.contentGroups || createMockContentGroups(); + cohortSettings = options.cohortSettings || createMockCohortSettings(true); + cohortSettings.url = '/mock_service/cohorts/settings'; cohorts.url = '/mock_service/cohorts'; + + cohortDiscussions = options.cohortDiscussions || createMockCohortDiscussions(); + cohortDiscussions.url = '/mock_service/cohorts/discussion/topics'; + requests = AjaxHelpers.requests(test); cohortsView = new CohortsView({ model: cohorts, contentGroups: contentGroups, + cohortSettings: cohortSettings, context: { + discussionTopicsSettingsModel: cohortDiscussions, uploadCohortsCsvUrl: MOCK_UPLOAD_COHORTS_CSV_URL, studioAdvancedSettingsUrl: MOCK_STUDIO_ADVANCED_SETTINGS_URL, studioGroupConfigurationsUrl: MOCK_STUDIO_GROUP_CONFIGURATIONS_URL } }); + cohortsView.render(); if (options && options.selectCohort) { cohortsView.$('.cohort-select').val(options.selectCohort.toString()).change(); @@ -73,13 +165,13 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe AjaxHelpers.respondWithJson(requests, createMockCohorts(catCount, dogCount)); }; - expectCohortAddRequest = function(name, groupId, userPartitionId) { + expectCohortAddRequest = function(name, groupId, userPartitionId, assignmentType) { AjaxHelpers.expectJsonRequest( requests, 'POST', '/mock_service/cohorts', { name: name, user_count: 0, - assignment_type: '', + assignment_type: assignmentType, group_id: groupId, user_partition_id: userPartitionId } @@ -130,7 +222,7 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe }); }; - verifyHeader = function(expectedCohortId, expectedTitle, expectedCount) { + verifyHeader = function(expectedCohortId, expectedTitle, expectedCount, assignmentType) { var header = cohortsView.$('.cohort-management-group-header'); expect(cohortsView.$('.cohort-select').val()).toBe(expectedCohortId.toString()); expect(cohortsView.$('.cohort-select option:selected').text()).toBe( @@ -147,6 +239,11 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe {count: expectedCount} ) ); + assignmentType = assignmentType || MOCK_MANUAL_ASSIGNMENT; + var manualMessage = "Students are added to this cohort only when you provide their email addresses or usernames on this page."; + var randomMessage = "Students are added to this cohort automatically."; + var message = (assignmentType == MOCK_MANUAL_ASSIGNMENT) ? manualMessage : randomMessage; + expect(header.find('.cohort-management-group-setup .setup-value').text().trim().split('\n')[0]).toBe(message); }; saveFormAndExpectErrors = function(action, errors) { @@ -164,17 +261,58 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe verifyDetailedMessage(expectedTitle, 'error', errors); }; + showAndAssertDiscussionTopics = function(that) { + + createCohortsView(that); + + // Should see the control to toggle cohort discussions. + expect(cohortsView.$(discussionsToggle)).not.toHaveClass('is-hidden'); + // But discussions form should not be visible until toggle is clicked. + expect(cohortsView.$(inlineDiscussionsFormCss).length).toBe(0); + expect(cohortsView.$(courseWideDiscussionsFormCss).length).toBe(0); + + expect(cohortsView.$(discussionsToggle).text()). + toContain('Specify whether discussion topics are divided by cohort'); + + cohortsView.$(discussionsToggle).click(); + // After toggle is clicked, it should be hidden. + expect(cohortsView.$(discussionsToggle)).toHaveClass('is-hidden'); + + // Should see the course wide discussions form and its content + courseWideDiscussionsForm = cohortsView.$(courseWideDiscussionsFormCss); + expect(courseWideDiscussionsForm.length).toBe(1); + + expect(courseWideDiscussionsForm.text()). + toContain('Course-Wide Discussion Topics'); + expect(courseWideDiscussionsForm.text()). + toContain('Select the course-wide discussion topics that you want to divide by cohort.'); + + // Should see the inline discussions form and its content + inlineDiscussionsForm = cohortsView.$(inlineDiscussionsFormCss); + expect(inlineDiscussionsForm.length).toBe(1); + expect(inlineDiscussionsForm.text()). + toContain('Content-Specific Discussion Topics'); + expect(inlineDiscussionsForm.text()). + toContain('Specify whether content-specific discussion topics are divided by cohort.'); + }; + unknownUserMessage = function (name) { return "Unknown user: " + name; }; beforeEach(function () { - setFixtures('
${_("To manage beta tester roles and cohorts, visit the Membership section of the Instructor Dashboard.")}
%else:${_("To manage beta tester roles, visit the Membership section of the Instructor Dashboard.")}
diff --git a/lms/templates/instructor/instructor_dashboard_2/cohort-discussions-category.underscore b/lms/templates/instructor/instructor_dashboard_2/cohort-discussions-category.underscore new file mode 100644 index 0000000000..735f4f0ad6 --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/cohort-discussions-category.underscore @@ -0,0 +1,10 @@ ++ + <%- gettext("There must be one cohort to which students can automatically be assigned.") %> +
+ <% } %> +