diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 0857fdd4ce..c87db7e076 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -9,8 +9,9 @@ from django.test import TestCase from django.test.utils import override_settings from contentstore import utils +from contentstore.tests.utils import CourseTestCase from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location from xmodule.modulestore.django import modulestore @@ -260,3 +261,57 @@ class XBlockVisibilityTestCase(TestCase): modulestore().publish(location, self.dummy_user) return vertical + + +class ReleaseDateSourceTest(CourseTestCase): + """Tests for finding the source of an xblock's release date.""" + + def setUp(self): + super(ReleaseDateSourceTest, self).setUp() + + self.chapter = ItemFactory.create(category='chapter', parent_location=self.course.location) + self.sequential = ItemFactory.create(category='sequential', parent_location=self.chapter.location) + self.vertical = ItemFactory.create(category='vertical', parent_location=self.sequential.location) + + # Read again so that children lists are accurate + self.chapter = self.store.get_item(self.chapter.location) + self.sequential = self.store.get_item(self.sequential.location) + self.vertical = self.store.get_item(self.vertical.location) + + self.date_one = datetime(1980, 1, 1, tzinfo=UTC) + self.date_two = datetime(2020, 1, 1, tzinfo=UTC) + + def _update_release_dates(self, chapter_start, sequential_start, vertical_start): + """Sets the release dates of the chapter, sequential, and vertical""" + self.chapter.start = chapter_start + self.chapter = self.store.update_item(self.chapter, ModuleStoreEnum.UserID.test) + self.sequential.start = sequential_start + self.sequential = self.store.update_item(self.sequential, ModuleStoreEnum.UserID.test) + self.vertical.start = vertical_start + self.vertical = self.store.update_item(self.vertical, ModuleStoreEnum.UserID.test) + + def _verify_release_date_source(self, item, expected_source): + """Helper to verify that the release date source of a given item matches the expected source""" + source = utils.find_release_date_source(item) + self.assertEqual(source.location, expected_source.location) + self.assertEqual(source.start, expected_source.start) + + def test_chapter_source_for_vertical(self): + """Tests a vertical's release date being set by its chapter""" + self._update_release_dates(self.date_one, self.date_one, self.date_one) + self._verify_release_date_source(self.vertical, self.chapter) + + def test_sequential_source_for_vertical(self): + """Tests a vertical's release date being set by its sequential""" + self._update_release_dates(self.date_one, self.date_two, self.date_two) + self._verify_release_date_source(self.vertical, self.sequential) + + def test_chapter_source_for_sequential(self): + """Tests a sequential's release date being set by its chapter""" + self._update_release_dates(self.date_one, self.date_one, self.date_one) + self._verify_release_date_source(self.sequential, self.chapter) + + def test_sequential_source_for_sequential(self): + """Tests a sequential's release date being set by itself""" + self._update_release_dates(self.date_one, self.date_two, self.date_two) + self._verify_release_date_source(self.sequential, self.sequential) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 54109d9243..9b1b935cee 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -187,6 +187,28 @@ def is_xblock_visible_to_students(xblock): return True +def find_release_date_source(xblock): + """ + Finds the ancestor of xblock that set its release date. + """ + + # Stop searching at the section level + if xblock.category == 'chapter': + return xblock + + parent_location = modulestore().get_parent_location(xblock.location, + revision=ModuleStoreEnum.RevisionOption.draft_preferred) + # Orphaned xblocks set their own release date + if not parent_location: + return xblock + + parent = modulestore().get_item(parent_location) + if parent.start != xblock.start: + return xblock + else: + return find_release_date_source(parent) + + def add_extra_panel_tab(tab_type, course): """ Used to add the panel tab to a course if it does not exist. diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index f7eb285bcc..efd6ef6526 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -4,6 +4,8 @@ from __future__ import absolute_import import hashlib import logging from uuid import uuid4 +from datetime import datetime +from pytz import UTC from collections import OrderedDict from functools import partial @@ -26,6 +28,9 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from xmodule.modulestore.inheritance import own_metadata from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW + +from xmodule.course_module import DEFAULT_START_DATE +from contentstore.utils import find_release_date_source from django.contrib.auth.models import User from util.date_utils import get_default_time_display @@ -591,6 +596,9 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F """ published = modulestore().has_item(xblock.location, revision=ModuleStoreEnum.RevisionOption.published_only) + # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set + release_date = get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None + def safe_get_username(user_id): """ Guard against bad user_ids, like the infamous "**replace_user**". @@ -619,6 +627,9 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "published_on": get_default_time_display(xblock.published_date) if xblock.published_date else None, "published_by": safe_get_username(xblock.published_by), 'studio_url': xblock_studio_url(xblock), + "released_to_students": datetime.now(UTC) > xblock.start, + "release_date": release_date, + "release_date_from": _get_release_date_from(xblock) if release_date else None, } if data is not None: xblock_info["data"] = data @@ -677,3 +688,15 @@ def _create_xblock_child_info(xblock, include_children_predicate=NEVER): ) for child in xblock.get_children() ] return child_info + + +def _get_release_date_from(xblock): + """ + Returns a string representation of the section or subsection that sets the xblock's release date + """ + source = find_release_date_source(xblock) + # Translators: this will be a part of the release date message. + # For example, 'Released: Jul 02, 2014 at 4:00 UTC with Section "Week 1"' + return _('{section_or_subsection} "{display_name}"').format( + section_or_subsection=xblock_type_display_name(source), + display_name=source.display_name_with_default) diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 1005e0dccd..46fccebf73 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -30,11 +30,6 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu * 2) Edits have been made to the xblock since the last published version. */ "has_changes": null, - /** - * True iff a published version of the xblock exists with a release date in the past, - * and the xblock is not locked. - */ - "released_to_students": null, /** * True iff a published version of the xblock exists. */ @@ -60,13 +55,19 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu * User who last published the xblock, or null if never published. */ "published_by": null, + /** + * True iff the release date of the xblock is in the past. + */ + "released_to_students": null, /** * If the xblock is published, the date on which it will be released to students. + * This can be null if the release date is unscheduled. */ "release_date": null, /** * The xblock which is determining the release date. For instance, for a unit, * this will either be the parent subsection or the grandparent section. + * This can be null if the release date is unscheduled. */ "release_date_from":null }, diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js index 505e80467e..8789473ec6 100644 --- a/cms/static/js/spec/views/pages/container_subviews_spec.js +++ b/cms/static/js/spec/views/pages/container_subviews_spec.js @@ -102,6 +102,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin publishButtonCss = ".action-publish", discardChangesButtonCss = ".action-discard", lastDraftCss = ".wrapper-last-draft", + releaseDateTitleCss = ".wrapper-release .title", + releaseDateContentCss = ".wrapper-release .copy", lastRequest, promptSpies, sendDiscardChangesToServer; lastRequest = function() { return requests[requests.length - 1]; }; @@ -276,6 +278,43 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin expect(containerPage.$(lastDraftCss).text()). toContain("Draft saved on Jul 02, 2014 at 14:20 UTC by joe"); }); + + it('renders the release date correctly when unreleased', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({ "id": "locator-container", "published": true, "released_to_students": false, + "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"'}); + expect(containerPage.$(releaseDateTitleCss).text()).toContain("Scheduled:"); + expect(containerPage.$(releaseDateContentCss).text()). + toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); + }); + + it('renders the release date correctly when released', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({ "id": "locator-container", "published": true, "released_to_students": true, + "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' }); + expect(containerPage.$(releaseDateTitleCss).text()).toContain("Released:"); + expect(containerPage.$(releaseDateContentCss).text()). + toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); + }); + + it('renders the release date correctly when the release date is not set', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({ "id": "locator-container", "published": true, "released_to_students": false, + "release_date": null, "release_date_from": null }); + expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:"); + expect(containerPage.$(releaseDateContentCss).text()).toContain("Unscheduled"); + }); + + it('renders the release date correctly when the unit is not published', function () { + renderContainerPage(mockContainerXBlockHtml, this); + fetch({ "id": "locator-container", "published": false, "released_to_students": true, + "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' }); + // Force a render because none of the fetched fields will trigger a render + containerPage.xblockPublisher.render(); + expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:"); + expect(containerPage.$(releaseDateContentCss).text()). + toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"'); + }); }); describe("PublishHistory", function () { diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index 8bc9335155..da61925849 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -84,7 +84,10 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ edited_on: this.model.get('edited_on'), edited_by: this.model.get('edited_by'), published_on: this.model.get('published_on'), - published_by: this.model.get('published_by') + published_by: this.model.get('published_by'), + released_to_students: this.model.get('released_to_students'), + release_date: this.model.get('release_date'), + release_date_from: this.model.get('release_date_from') })); return this; diff --git a/cms/templates/js/publish-xblock.underscore b/cms/templates/js/publish-xblock.underscore index 13cd1b3f65..70352ad3b7 100644 --- a/cms/templates/js/publish-xblock.underscore +++ b/cms/templates/js/publish-xblock.underscore @@ -25,14 +25,30 @@
- - - - - - - - + + diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 1d71819f2d..89f49f9ce2 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -22,6 +22,7 @@ log = logging.getLogger(__name__) # Make '_' a no-op so we can scrape strings _ = lambda text: text +DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=UTC()) class StringOrDate(Date): def from_json(self, value): @@ -170,7 +171,7 @@ class CourseFields(object): enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) start = Date(help="Start time when this module is visible", - default=datetime(2030, 1, 1, tzinfo=UTC()), + default=DEFAULT_START_DATE, scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = String(