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:
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
113
openedx/features/course_experience/course_updates.py
Normal file
113
openedx/features/course_experience/course_updates.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
121
openedx/features/course_experience/tests/test_course_updates.py
Normal file
121
openedx/features/course_experience/tests/test_course_updates.py
Normal 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')
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user