AA-289: rewrite how course updates behave when dismissed

Previously, dismissing one course update would disable all future
updates. But that feels a bit limiting.

Instead, these are the new rules:
- If the newest update has not been dismissed yet, it gets displayed.
- If the newest update has been dismissed, we display nothing.
- Editing the newest update will cause it to be displayed again.
- New updates get displayed, even if previous updates were dismissed.

This commit includes a light refactoring and addition of some utility
methods to help manage the above.

It also notes that the learning MFE does not use the "latest_update"
waffle option, which controls the labeling of the update message.
This commit is contained in:
Michael Terry
2020-11-09 09:34:44 -05:00
parent 151bd13666
commit 40fbef4af2
11 changed files with 384 additions and 236 deletions

View File

@@ -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

View File

@@ -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__)

View File

@@ -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)

View File

@@ -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

View File

@@ -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')

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()