diff --git a/lms/djangoapps/course_home_api/outline/v1/views.py b/lms/djangoapps/course_home_api/outline/v1/views.py index f79730a01e46d1512f51e79281f7f3a892d22fec..b2a64703c129964304f0ff1f22d9dc58a868c9cd 100644 --- a/lms/djangoapps/course_home_api/outline/v1/views.py +++ b/lms/djangoapps/course_home_api/outline/v1/views.py @@ -30,13 +30,13 @@ from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course from lms.djangoapps.courseware.date_summary import TodaysDate from lms.djangoapps.courseware.masquerade import setup_masquerade from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.user_api.course_tag.api import get_course_tag, set_course_tag from openedx.features.course_duration_limits.access import generate_course_expired_message -from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, LATEST_UPDATE_FLAG +from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG from openedx.features.course_experience.course_tools import CourseToolsPluginManager +from openedx.features.course_experience.course_updates import ( + dismiss_current_update_for_user, get_current_update_for_user, +) from openedx.features.course_experience.utils import get_course_outline_block_tree -from openedx.features.course_experience.views.latest_update import LatestUpdateFragmentView -from openedx.features.course_experience.views.welcome_message import PREFERENCE_KEY, WelcomeMessageFragmentView from openedx.features.discounts.utils import generate_offer_html from common.djangoapps.student.models import CourseEnrollment from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE @@ -168,12 +168,7 @@ class OutlineTabView(RetrieveAPIView): offer_html = show_enrolled and generate_offer_html(request.user, course_overview) course_expired_html = show_enrolled and generate_course_expired_message(request.user, course_overview) - welcome_message_html = None - if show_enrolled: - if LATEST_UPDATE_FLAG.is_enabled(course_key): - welcome_message_html = LatestUpdateFragmentView().latest_update_html(request, course) - elif get_course_tag(request.user, course_key, PREFERENCE_KEY) != 'False': - welcome_message_html = WelcomeMessageFragmentView().welcome_message_html(request, course) + welcome_message_html = show_enrolled and get_current_update_for_user(request, course) enroll_alert = { 'can_enroll': True, @@ -284,7 +279,8 @@ def dismiss_welcome_message(request): try: course_key = CourseKey.from_string(course_id) - set_course_tag(request.user, course_key, PREFERENCE_KEY, 'False') + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + dismiss_current_update_for_user(request, course) return Response({'message': _('Welcome message successfully dismissed.')}) except Exception: raise UnableToDismissWelcomeMessage diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py index bc8eb3140cd68b3862f0da6745387e0af4aaa62c..5599fbc6191beb24b00a3e5ec7f42b1e8dee80ff 100644 --- a/openedx/features/course_experience/__init__.py +++ b/openedx/features/course_experience/__init__.py @@ -72,7 +72,8 @@ UPGRADE_DEADLINE_MESSAGE = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'upgrade_dead # .. toggle_creation_date: 2017-09-11 # .. toggle_target_removal_date: None # .. toggle_warnings: This is meant to be configured using waffle_utils course override only. Either do not create the -# actual waffle flag, or be sure to unset the flag even for Superusers. +# actual waffle flag, or be sure to unset the flag even for Superusers. This is no longer used in the learning MFE +# and can be removed when the outline tab is fully moved to the learning MFE. # .. toggle_tickets: None LATEST_UPDATE_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'latest_update', __name__) diff --git a/openedx/features/course_experience/course_updates.py b/openedx/features/course_experience/course_updates.py new file mode 100644 index 0000000000000000000000000000000000000000..37de393ca1a8aea84c55f3a711177d88d133b046 --- /dev/null +++ b/openedx/features/course_experience/course_updates.py @@ -0,0 +1,113 @@ +""" +Utilities for course updates. +""" + +import hashlib +from datetime import datetime + +from lms.djangoapps.courseware.courses import get_course_info_section_module +from openedx.core.djangoapps.user_api.course_tag.api import get_course_tag, set_course_tag + +STATUS_VISIBLE = 'visible' +STATUS_DELETED = 'deleted' + +VIEW_WELCOME_MESSAGE_KEY = 'view-welcome-message' + + +def _calculate_update_hash(update): + """ + Returns a hash of the content of a course update. Does not need to be secure. + """ + hasher = hashlib.md5() + hasher.update(update['content'].encode('utf-8')) + return hasher.hexdigest() + + +def _get_dismissed_hashes(user, course_key): + """ + Returns a list of dismissed hashes, or None if all updates have been dismissed. + """ + view_welcome_message = get_course_tag(user, course_key, VIEW_WELCOME_MESSAGE_KEY) + if view_welcome_message == 'False': # legacy value, which dismisses all updates + return None + return view_welcome_message.split(',') if view_welcome_message else [] + + +def _add_dismissed_hash(user, course_key, new_hash): + """ + Add a new hash to the list of previously dismissed updates. + + Overwrites a 'False' value with the current hash. Though we likely won't end up in that situation, since + a 'False' value will never show the update to the user to dismiss in the first place. + """ + hashes = _get_dismissed_hashes(user, course_key) or [] + hashes.append(new_hash) + set_course_tag(user, course_key, VIEW_WELCOME_MESSAGE_KEY, ','.join(hashes)) + + +def _safe_parse_date(date): + """ + Since this is used solely for ordering purposes, use today's date as a default + """ + try: + return datetime.strptime(date, '%B %d, %Y') + except ValueError: # occurs for ill-formatted date values + return datetime.today() + + +def get_ordered_updates(request, course): + """ + Returns all public course updates in reverse chronological order, including dismissed ones. + """ + info_module = get_course_info_section_module(request, request.user, course, 'updates') + if not info_module: + return [] + + info_block = getattr(info_module, '_xmodule', info_module) + ordered_updates = [update for update in info_module.items if update.get('status') == STATUS_VISIBLE] + ordered_updates.sort( + key=lambda item: (_safe_parse_date(item['date']), item['id']), + reverse=True + ) + for update in ordered_updates: + update['content'] = info_block.system.replace_urls(update['content']) + return ordered_updates + + +def get_current_update_for_user(request, course): + """ + Returns the current (most recent) course update HTML. + + Some rules about when we show updates: + - If the newest update has not been dismissed yet, it gets returned. + - If the newest update has been dismissed, we will return None. + - Will return a previously-dismissed newest update if it has been edited since being dismissed. + - If a current update is deleted and an already dismissed update is now the newest one, we don't want to show that. + """ + updates = get_ordered_updates(request, course) + if not updates: + return None + + dismissed_hashes = _get_dismissed_hashes(request.user, course.id) + if dismissed_hashes is None: # all updates dismissed + return None + + update_hash = _calculate_update_hash(updates[0]) + if update_hash in dismissed_hashes: + return None + + return updates[0]['content'] + + +def dismiss_current_update_for_user(request, course): + """ + Marks the current course update for this user as dismissed. + + See get_current_update_for_user for what "current course update" means in practice. + """ + updates = get_ordered_updates(request, course) + if not updates: + return None + + update_hash = _calculate_update_hash(updates[0]) + _add_dismissed_hash(request.user, course.id, update_hash) diff --git a/openedx/features/course_experience/tests/__init__.py b/openedx/features/course_experience/tests/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..7eb664cf7a6c986a1589221f820d3c3ccd9954fa 100644 --- a/openedx/features/course_experience/tests/__init__.py +++ b/openedx/features/course_experience/tests/__init__.py @@ -0,0 +1,104 @@ +""" +Common test code for course_experience, like shared base classes. +""" + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.courseware.courses import get_course_info_usage_key +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +class BaseCourseUpdatesTestCase(SharedModuleStoreTestCase): + """Base class for working with course updates.""" + @classmethod + def setUpClass(cls): + """Set up the simplest course possible.""" + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + with cls.store.default_store(ModuleStoreEnum.Type.split): + cls.course = CourseFactory.create() + with cls.store.bulk_operations(cls.course.id): + # Create a basic course structure + chapter = ItemFactory.create(category='chapter', parent_location=cls.course.location) + section = ItemFactory.create(category='sequential', parent_location=chapter.location) + ItemFactory.create(category='vertical', parent_location=section.location) + + @classmethod + def setUpTestData(cls): + """Set up and enroll our fake user in the course.""" + super().setUpTestData() + cls.user = UserFactory(password=cls.TEST_PASSWORD) + CourseEnrollment.enroll(cls.user, cls.course.id) + + def setUp(self): + super().setUp() + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + + def tearDown(self): + self.remove_course_updates() + super().tearDown() + + def remove_course_updates(self, user=None, course=None): + """Remove any course updates in the specified course.""" + user = user or self.user + course = course or self.course + updates_usage_key = get_course_info_usage_key(course, 'updates') + try: + course_updates = modulestore().get_item(updates_usage_key) + modulestore().delete_item(course_updates.location, user.id) + except (ItemNotFoundError, ValueError): + pass + + def edit_course_update(self, index, content=None, course=None, user=None, date=None, deleted=None): + """Edits a course update item. Only changes explicitly provided parameters.""" + user = user or self.user + course = course or self.course + updates_usage_key = get_course_info_usage_key(course, 'updates') + course_updates = modulestore().get_item(updates_usage_key) + for item in course_updates.items: + if item['id'] == index: + if date is not None: + item['date'] = date + if content is not None: + item['content'] = content + if deleted is not None: + item['status'] = 'deleted' if deleted else 'visible' + break + modulestore().update_item(course_updates, user.id) + + def create_course_update(self, content, course=None, user=None, date='December 31, 1999', deleted=False): + """Creates a test welcome message for the specified course.""" + user = user or self.user + course = course or self.course + updates_usage_key = get_course_info_usage_key(course, 'updates') + try: + course_updates = modulestore().get_item(updates_usage_key) + except ItemNotFoundError: + course_updates = self.create_course_updates_block(course=course, user=user) + item = { + 'id': len(course_updates.items) + 1, + 'date': date, + 'content': content, + 'status': 'deleted' if deleted else 'visible', + } + course_updates.items.append(item) + modulestore().update_item(course_updates, user.id) + return item + + def create_course_updates_block(self, course=None, user=None): + """Create a course updates block.""" + user = user or self.user + course = course or self.course + updates_usage_key = get_course_info_usage_key(course, 'updates') + course_updates = modulestore().create_item( + user.id, + updates_usage_key.course_key, + updates_usage_key.block_type, + block_id=updates_usage_key.block_id + ) + course_updates.data = '' + return course_updates diff --git a/openedx/features/course_experience/tests/test_course_updates.py b/openedx/features/course_experience/tests/test_course_updates.py new file mode 100644 index 0000000000000000000000000000000000000000..7646788152252e509563b07e35e4e262ec4f34d6 --- /dev/null +++ b/openedx/features/course_experience/tests/test_course_updates.py @@ -0,0 +1,121 @@ +""" +Tests for the course updates utility methods. +""" + +from django.test.client import RequestFactory + +from openedx.core.djangoapps.user_api.course_tag.api import get_course_tag, set_course_tag +from openedx.features.course_experience.course_updates import ( + dismiss_current_update_for_user, get_current_update_for_user, get_ordered_updates, +) +from openedx.features.course_experience.tests import BaseCourseUpdatesTestCase + + +class TestCourseUpdatesUtils(BaseCourseUpdatesTestCase): + """Tests for the course update utility methods.""" + + UPDATES_TAG = 'view-welcome-message' + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.request = RequestFactory().get('/') + cls.request.user = cls.user + + def test_update_structure(self): + """Test that returned item dictionary is as we expect.""" + content = '<em>HTML Content</em>' + date = 'January 1, 2000' + self.create_course_update(content, date=date) + updates = get_ordered_updates(self.request, self.course) + self.assertListEqual(updates, [{ + 'id': 1, + 'content': content, + 'date': date, + 'status': 'visible', + }]) + + def test_ordered_updates(self): + """Test that order of returned items follows our rules.""" + first = self.create_course_update('2000', date='January 1, 2000') + second = self.create_course_update('2017', date='January 1, 2017') + third = self.create_course_update('Also 2017', date='January 1, 2017') + injected = self.create_course_update('Injected out of order', date='January 1, 2010') + ill_formed = self.create_course_update('Ill-formed date is parsed as now()', date='foobar') + self.create_course_update('Deleted is ignored', deleted=True) + updates = get_ordered_updates(self.request, self.course) + self.assertListEqual(updates, [ill_formed, third, second, injected, first]) + + def test_replace_urls(self): + """We should be replacing static URLs with course specific ones.""" + self.create_course_update("<img src='/static/img.png'>") + updates = get_ordered_updates(self.request, self.course) + expected = "<img src='/asset-v1:{org}+{course}+{run}+type@asset+block/img.png'>".format( + org=self.course.id.org, + course=self.course.id.course, + run=self.course.id.run, + ) + self.assertEqual(updates[0]['content'], expected) + + def test_ordered_update_includes_dismissed_updates(self): + """Ordered update list should still have dismissed updates.""" + self.create_course_update('Dismissed') + dismiss_current_update_for_user(self.request, self.course) + updates = get_ordered_updates(self.request, self.course) + self.assertEqual(len(updates), 1) + + def test_get_current_update_is_newest(self): + """Tests that the current update is also the newest.""" + self.create_course_update('Oldest', date='January 1, 1900') + self.create_course_update('New', date='January 1, 2017') + self.create_course_update('Oldish', date='January 1, 2000') + self.assertEqual(get_current_update_for_user(self.request, self.course), 'New') + + def test_get_current_update_when_dismissed(self): + """Tests that a dismissed update is not returned.""" + self.create_course_update('Dismissed') + dismiss_current_update_for_user(self.request, self.course) + self.assertIsNone(get_current_update_for_user(self.request, self.course)) + + def test_get_current_update_when_dismissed_but_edited(self): + """Tests that a dismissed but edited update is returned.""" + self.create_course_update('Original') + dismiss_current_update_for_user(self.request, self.course) + self.assertIsNone(get_current_update_for_user(self.request, self.course)) + self.edit_course_update(1, content='Edited') + self.assertIsNotNone(get_current_update_for_user(self.request, self.course)) + + def test_get_current_update_remembers_dismissals(self): + """Tests that older dismissed updates are remembered.""" + self.create_course_update('First') + self.create_course_update('Second') + dismiss_current_update_for_user(self.request, self.course) + self.create_course_update('Third') + dismiss_current_update_for_user(self.request, self.course) + self.create_course_update('Fourth') + + self.assertEqual(get_current_update_for_user(self.request, self.course), 'Fourth') + self.edit_course_update(4, deleted=True) + self.assertIsNone(get_current_update_for_user(self.request, self.course)) + self.edit_course_update(3, deleted=True) + self.assertIsNone(get_current_update_for_user(self.request, self.course)) + self.edit_course_update(2, deleted=True) + self.assertEqual(get_current_update_for_user(self.request, self.course), 'First') + + def test_legacy_ignore_all_support(self): + """Storing 'False' as the dismissal ignores all updates.""" + self.create_course_update('First') + self.assertEqual(get_current_update_for_user(self.request, self.course), 'First') + + set_course_tag(self.user, self.course.id, self.UPDATES_TAG, 'False') + self.assertIsNone(get_current_update_for_user(self.request, self.course)) + + def test_dismissal_hashing(self): + """Confirm that the stored dismissal values are what we expect, to catch accidentally changing formats.""" + self.create_course_update('First') + dismiss_current_update_for_user(self.request, self.course) + self.create_course_update('Second') + dismiss_current_update_for_user(self.request, self.course) + + tag = get_course_tag(self.user, self.course.id, self.UPDATES_TAG) + self.assertEqual(tag, '7fb55ed0b7a30342ba6da306428cae04,c22cf8376b1893dcfcef0649fe1a7d87') diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py index 5c727ac54ae43297faca23f088248e8b6ca432a1..389d2f7e90d90bf18decc359a3e701ec1725d7b6 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -54,6 +54,7 @@ from openedx.features.course_experience import ( SHOW_REVIEWS_TOOL_FLAG, SHOW_UPGRADE_MSG_ON_COURSE_HOME ) +from openedx.features.course_experience.tests import BaseCourseUpdatesTestCase from openedx.features.discounts.applicability import get_discount_expiration_date from openedx.features.discounts.utils import REV1008_EXPERIMENT_ID, format_strikeout_price from common.djangoapps.student.models import CourseEnrollment, FBEEnrollmentExclusion @@ -61,12 +62,11 @@ from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.date_utils import strftime_localized from xmodule.course_module import COURSE_VISIBILITY_PRIVATE, COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls from ... import COURSE_PRE_START_ACCESS_FLAG, ENABLE_COURSE_GOALS from .helpers import add_course_mode, remove_course_mode -from .test_course_updates import create_course_update, remove_course_updates TEST_PASSWORD = 'test' TEST_CHAPTER_NAME = 'Test Chapter' @@ -113,7 +113,7 @@ def course_home_url_from_string(course_key_string): ) -class CourseHomePageTestCase(SharedModuleStoreTestCase): +class CourseHomePageTestCase(BaseCourseUpdatesTestCase): """ Base class for testing the course home page. """ @@ -154,10 +154,8 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase): @classmethod def setUpTestData(cls): """Set up and enroll our fake user in the course.""" - super(CourseHomePageTestCase, cls).setUpTestData() + super().setUpTestData() cls.staff_user = StaffFactory(course_key=cls.course.id, password=TEST_PASSWORD) - cls.user = UserFactory(password=TEST_PASSWORD) - CourseEnrollment.enroll(cls.user, cls.course.id) def create_future_course(self, specific_date=None): """ @@ -170,17 +168,9 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase): class TestCourseHomePage(CourseHomePageTestCase): - def setUp(self): - super(TestCourseHomePage, self).setUp() - self.client.login(username=self.user.username, password=TEST_PASSWORD) - - def tearDown(self): - remove_course_updates(self.user, self.course) - super(TestCourseHomePage, self).tearDown() - def test_welcome_message_when_unified(self): # Create a welcome message - create_course_update(self.course, self.user, TEST_WELCOME_MESSAGE) + self.create_course_update(TEST_WELCOME_MESSAGE) url = course_home_url(self.course) response = self.client.get(url) @@ -189,7 +179,7 @@ class TestCourseHomePage(CourseHomePageTestCase): @override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=True) def test_welcome_message_when_not_unified(self): # Create a welcome message - create_course_update(self.course, self.user, TEST_WELCOME_MESSAGE) + self.create_course_update(TEST_WELCOME_MESSAGE) url = course_home_url(self.course) response = self.client.get(url) @@ -204,7 +194,7 @@ class TestCourseHomePage(CourseHomePageTestCase): response = self.client.get(url) self.assertNotContains(response, TEST_COURSE_UPDATES_TOOL, status_code=200) - create_course_update(self.course, self.user, TEST_UPDATE_MESSAGE) + self.create_course_update(TEST_UPDATE_MESSAGE) url = course_home_url(self.course) response = self.client.get(url) self.assertContains(response, TEST_COURSE_UPDATES_TOOL, status_code=200) @@ -249,18 +239,15 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): """ def setUp(self): - super(TestCourseHomePageAccess, self).setUp() + super().setUp() + self.client.logout() # start with least access and add access back in the various test cases # Make this a verified course so that an upgrade message might be shown add_course_mode(self.course, mode_slug=CourseMode.AUDIT) add_course_mode(self.course) # Add a welcome message - create_course_update(self.course, self.staff_user, TEST_WELCOME_MESSAGE) - - def tearDown(self): - remove_course_updates(self.staff_user, self.course) - super(TestCourseHomePageAccess, self).tearDown() + self.create_course_update(TEST_WELCOME_MESSAGE) @override_waffle_flag(SHOW_REVIEWS_TOOL_FLAG, active=True) @ddt.data( diff --git a/openedx/features/course_experience/tests/views/test_course_updates.py b/openedx/features/course_experience/tests/views/test_course_updates.py index b9113290c1a7b6201abf57e8551040804a36f9d1..3a6409f63a09d6f1d04510df1708715fa9c8fda7 100644 --- a/openedx/features/course_experience/tests/views/test_course_updates.py +++ b/openedx/features/course_experience/tests/views/test_course_updates.py @@ -2,25 +2,14 @@ Tests for the course updates page. """ - from datetime import datetime -import six from django.urls import reverse -from lms.djangoapps.courseware.courses import get_course_info_usage_key from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.course_experience.views.course_updates import STATUS_VISIBLE -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import UserFactory -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls - -TEST_PASSWORD = 'test' +from openedx.features.course_experience.tests import BaseCourseUpdatesTestCase +from xmodule.modulestore.tests.factories import check_mongo_calls QUERY_COUNT_TABLE_BLACKLIST = WAFFLE_TABLES @@ -32,93 +21,18 @@ def course_updates_url(course): return reverse( 'openedx.course_experience.course_updates', kwargs={ - 'course_id': six.text_type(course.id), + 'course_id': str(course.id), } ) -def create_course_update(course, user, content, date='December 31, 1999'): - """ - Creates a test welcome message for the specified course. - """ - updates_usage_key = get_course_info_usage_key(course, 'updates') - try: - course_updates = modulestore().get_item(updates_usage_key) - except ItemNotFoundError: - course_updates = create_course_updates_block(course, user) - course_updates.items.append({ - "id": len(course_updates.items) + 1, - "date": date, - "content": content, - "status": STATUS_VISIBLE - }) - modulestore().update_item(course_updates, user.id) - - -def create_course_updates_block(course, user): - """ - Create a course updates block. - """ - updates_usage_key = get_course_info_usage_key(course, 'updates') - course_updates = modulestore().create_item( - user.id, - updates_usage_key.course_key, - updates_usage_key.block_type, - block_id=updates_usage_key.block_id - ) - course_updates.data = '' - return course_updates - - -def remove_course_updates(user, course): - """ - Remove any course updates in the specified course. - """ - updates_usage_key = get_course_info_usage_key(course, 'updates') - try: - course_updates = modulestore().get_item(updates_usage_key) - modulestore().delete_item(course_updates.location, user.id) - except (ItemNotFoundError, ValueError): - pass - - -class TestCourseUpdatesPage(SharedModuleStoreTestCase): +class TestCourseUpdatesPage(BaseCourseUpdatesTestCase): """ Test the course updates page. """ - @classmethod - def setUpClass(cls): - """Set up the simplest course possible.""" - # pylint: disable=super-method-not-called - with super(TestCourseUpdatesPage, cls).setUpClassAndTestData(): - with cls.store.default_store(ModuleStoreEnum.Type.split): - cls.course = CourseFactory.create() - with cls.store.bulk_operations(cls.course.id): - # Create a basic course structure - chapter = ItemFactory.create(category='chapter', parent_location=cls.course.location) - section = ItemFactory.create(category='sequential', parent_location=chapter.location) - ItemFactory.create(category='vertical', parent_location=section.location) - - @classmethod - def setUpTestData(cls): - """Set up and enroll our fake user in the course.""" - cls.user = UserFactory(password=TEST_PASSWORD) - CourseEnrollment.enroll(cls.user, cls.course.id) - - def setUp(self): - """ - Set up for the tests. - """ - super(TestCourseUpdatesPage, self).setUp() - self.client.login(username=self.user.username, password=TEST_PASSWORD) - - def tearDown(self): - remove_course_updates(self.user, self.course) - super(TestCourseUpdatesPage, self).tearDown() - def test_view(self): - create_course_update(self.course, self.user, 'First Message') - create_course_update(self.course, self.user, 'Second Message') + self.create_course_update('First Message') + self.create_course_update('Second Message') url = course_updates_url(self.course) response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -127,7 +41,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase): def test_queries(self): ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) - create_course_update(self.course, self.user, 'First Message') + self.create_course_update('First Message') # Pre-fetch the view to populate any caches course_updates_url(self.course) diff --git a/openedx/features/course_experience/tests/views/test_welcome_message.py b/openedx/features/course_experience/tests/views/test_welcome_message.py index c75b93b947c3fd8562b4e3105ea13955089019ca..805ae431c230335ef5e1d74ca7507917e973c339 100644 --- a/openedx/features/course_experience/tests/views/test_welcome_message.py +++ b/openedx/features/course_experience/tests/views/test_welcome_message.py @@ -2,21 +2,10 @@ Tests for course welcome messages. """ - import ddt -import six from django.urls import reverse -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import UserFactory -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory - -from .test_course_updates import create_course_update, remove_course_updates - -TEST_PASSWORD = 'test' -TEST_WELCOME_MESSAGE = '<h2>Welcome!</h2>' +from openedx.features.course_experience.tests import BaseCourseUpdatesTestCase def welcome_message_url(course): @@ -26,7 +15,7 @@ def welcome_message_url(course): return reverse( 'openedx.course_experience.welcome_message_fragment_view', kwargs={ - 'course_id': six.text_type(course.id), + 'course_id': str(course.id), } ) @@ -38,7 +27,7 @@ def latest_update_url(course): return reverse( 'openedx.course_experience.latest_update_fragment_view', kwargs={ - 'course_id': six.text_type(course.id), + 'course_id': str(course.id), } ) @@ -50,58 +39,28 @@ def dismiss_message_url(course): return reverse( 'openedx.course_experience.dismiss_welcome_message', kwargs={ - 'course_id': six.text_type(course.id), + 'course_id': str(course.id), } ) @ddt.ddt -class TestWelcomeMessageView(ModuleStoreTestCase): +class TestWelcomeMessageView(BaseCourseUpdatesTestCase): """ Tests for the course welcome message fragment view. Also tests the LatestUpdate view because the functionality is similar. """ - def setUp(self): - """Set up the simplest course possible, then set up and enroll our fake user in the course.""" - super(TestWelcomeMessageView, self).setUp() - with self.store.default_store(ModuleStoreEnum.Type.split): - self.course = CourseFactory.create() - with self.store.bulk_operations(self.course.id): - # Create a basic course structure - chapter = ItemFactory.create(category='chapter', parent_location=self.course.location) - section = ItemFactory.create(category='sequential', parent_location=chapter.location) - ItemFactory.create(category='vertical', parent_location=section.location) - self.user = UserFactory(password=TEST_PASSWORD) - CourseEnrollment.enroll(self.user, self.course.id) - self.client.login(username=self.user.username, password=TEST_PASSWORD) - - def tearDown(self): - remove_course_updates(self.user, self.course) - super(TestWelcomeMessageView, self).tearDown() - @ddt.data(welcome_message_url, latest_update_url) def test_message_display(self, url_generator): - create_course_update(self.course, self.user, 'First Update', date='January 1, 2000') - create_course_update(self.course, self.user, 'Second Update', date='January 1, 2017') - create_course_update(self.course, self.user, 'Retroactive Update', date='January 1, 2010') + self.create_course_update('First Update', date='January 1, 2000') + self.create_course_update('Second Update', date='January 1, 2017') + self.create_course_update('Retroactive Update', date='January 1, 2010') response = self.client.get(url_generator(self.course)) self.assertEqual(response.status_code, 200) self.assertContains(response, 'Second Update') self.assertContains(response, 'Dismiss') - @ddt.data(welcome_message_url, latest_update_url) - def test_replace_urls(self, url_generator): - img_url = 'img.png' - create_course_update(self.course, self.user, u"<img src='/static/{url}'>".format(url=img_url)) - response = self.client.get(url_generator(self.course)) - self.assertContains(response, "/asset-v1:{org}+{course}+{run}+type@asset+block/{url}".format( - org=self.course.id.org, - course=self.course.id.course, - run=self.course.id.run, - url=img_url, - )) - @ddt.data(welcome_message_url, latest_update_url) def test_empty_message(self, url_generator): response = self.client.get(url_generator(self.course)) @@ -109,7 +68,7 @@ class TestWelcomeMessageView(ModuleStoreTestCase): def test_dismiss_welcome_message(self): # Latest update is dimssed in JS and has no server/backend component. - create_course_update(self.course, self.user, 'First Update', date='January 1, 2017') + self.create_course_update('First Update') response = self.client.get(welcome_message_url(self.course)) self.assertEqual(response.status_code, 200) diff --git a/openedx/features/course_experience/views/course_updates.py b/openedx/features/course_experience/views/course_updates.py index 726b52d1022187c4ed9cb1a5017ee961a3674139..e6c78c4e17ff651961b32733edc669b6ad09a7db 100644 --- a/openedx/features/course_experience/views/course_updates.py +++ b/openedx/features/course_experience/views/course_updates.py @@ -2,9 +2,6 @@ Views that handle course updates. """ - -from datetime import datetime - import six from django.contrib.auth.decorators import login_required from django.template.context_processors import csrf @@ -19,37 +16,7 @@ from lms.djangoapps.courseware.courses import get_course_info_section_module, ge from lms.djangoapps.courseware.views.views import CourseTabView from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from openedx.features.course_experience import default_course_url_name - -STATUS_VISIBLE = 'visible' -STATUS_DELETED = 'deleted' - - -def get_ordered_updates(request, course): - """ - Returns any course updates in reverse chronological order. - """ - info_module = get_course_info_section_module(request, request.user, course, 'updates') - - updates = info_module.items if info_module else [] - info_block = getattr(info_module, '_xmodule', info_module) if info_module else None - ordered_updates = [update for update in updates if update.get('status') == STATUS_VISIBLE] - ordered_updates.sort( - key=lambda item: (safe_parse_date(item['date']), item['id']), - reverse=True - ) - for update in ordered_updates: - update['content'] = info_block.system.replace_urls(update['content']) - return ordered_updates - - -def safe_parse_date(date): - """ - Since this is used solely for ordering purposes, use today's date as a default - """ - try: - return datetime.strptime(date, u'%B %d, %Y') - except ValueError: # occurs for ill-formatted date values - return datetime.today() +from openedx.features.course_experience.course_updates import get_ordered_updates class CourseUpdatesView(CourseTabView): diff --git a/openedx/features/course_experience/views/latest_update.py b/openedx/features/course_experience/views/latest_update.py index 9b1b9df9350708130de425580c21f28722749d36..0fe5c3ce727ab3fcdb5ea1211a47dd330475f2a8 100644 --- a/openedx/features/course_experience/views/latest_update.py +++ b/openedx/features/course_experience/views/latest_update.py @@ -14,7 +14,7 @@ from web_fragments.fragment import Fragment from lms.djangoapps.courseware.courses import get_course_with_access from openedx.core.djangoapps.plugin_api.views import EdxFragmentView -from openedx.features.course_experience.views.course_updates import get_ordered_updates +from openedx.features.course_experience.course_updates import get_current_update_for_user class LatestUpdateFragmentView(EdxFragmentView): @@ -47,9 +47,4 @@ class LatestUpdateFragmentView(EdxFragmentView): Returns the course's latest update message or None if it doesn't have one. """ # Return the course update with the most recent publish date - ordered_updates = get_ordered_updates(request, course) - content = None - if ordered_updates: - content = ordered_updates[0]['content'] - - return content + return get_current_update_for_user(request, course) diff --git a/openedx/features/course_experience/views/welcome_message.py b/openedx/features/course_experience/views/welcome_message.py index dfb2dc5e86061e360f8d946c252ffebf0d6a0aa8..aad304201ca921dc325f7947edddfd07ef13478c 100644 --- a/openedx/features/course_experience/views/welcome_message.py +++ b/openedx/features/course_experience/views/welcome_message.py @@ -13,11 +13,9 @@ from web_fragments.fragment import Fragment from lms.djangoapps.courseware.courses import get_course_with_access from openedx.core.djangoapps.plugin_api.views import EdxFragmentView -from openedx.core.djangoapps.user_api.course_tag.api import get_course_tag, set_course_tag - -from .course_updates import get_ordered_updates - -PREFERENCE_KEY = 'view-welcome-message' +from openedx.features.course_experience.course_updates import ( + dismiss_current_update_for_user, get_current_update_for_user, +) class WelcomeMessageFragmentView(EdxFragmentView): @@ -46,11 +44,8 @@ class WelcomeMessageFragmentView(EdxFragmentView): 'welcome_message_html': welcome_message_html, } - if get_course_tag(request.user, course_key, PREFERENCE_KEY) == 'False': - return None - else: - html = render_to_string('course_experience/welcome-message-fragment.html', context) - return Fragment(html) + html = render_to_string('course_experience/welcome-message-fragment.html', context) + return Fragment(html) @classmethod def welcome_message_html(cls, request, course): @@ -58,12 +53,7 @@ class WelcomeMessageFragmentView(EdxFragmentView): Returns the course's welcome message or None if it doesn't have one. """ # Return the course update with the most recent publish date - ordered_updates = get_ordered_updates(request, course) - content = None - if ordered_updates: - content = ordered_updates[0]['content'] - - return content + return get_current_update_for_user(request, course) @ensure_csrf_cookie @@ -72,5 +62,6 @@ def dismiss_welcome_message(request, course_id): Given the course_id in the request, disable displaying the welcome message for the user. """ course_key = CourseKey.from_string(course_id) - set_course_tag(request.user, course_key, PREFERENCE_KEY, 'False') + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + dismiss_current_update_for_user(request, course) return HttpResponse()