diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 2295045dda..f7017177de 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -73,6 +73,7 @@ from openedx.core.lib.xblock_utils import ( is_xblock_aside, get_aside_from_xblock, ) +from openedx.features.course_duration_limits.access import course_expiration_wrapper from student.models import anonymous_id_for_user, user_by_anonymous_id from student.roles import CourseBetaTesterRole from track import contexts @@ -729,6 +730,7 @@ def get_module_system_for_user( )) block_wrappers.append(partial(display_access_messages, user)) + block_wrappers.append(partial(course_expiration_wrapper, user)) if settings.FEATURES.get('DISPLAY_DEBUG_INFO_TO_STAFF'): if is_masquerading_as_specific_student(user, course_id): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 69541604cf..c76150ee08 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -10,6 +10,7 @@ from HTMLParser import HTMLParser from urllib import quote, urlencode from uuid import uuid4 from crum import set_current_request +from markupsafe import escape from completion.test_utils import CompletionWaffleTestMixin import ddt @@ -214,8 +215,8 @@ class IndexQueryTestCase(ModuleStoreTestCase): NUM_PROBLEMS = 20 @ddt.data( - (ModuleStoreEnum.Type.mongo, 10, 178), - (ModuleStoreEnum.Type.split, 4, 172), + (ModuleStoreEnum.Type.mongo, 10, 179), + (ModuleStoreEnum.Type.split, 4, 173), ) @ddt.unpack def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): @@ -2753,6 +2754,7 @@ class TestIndexViewWithCourseDurationLimits(ModuleStoreTestCase): with self.store.bulk_operations(self.course.id): self.chapter = ItemFactory.create(parent=self.course, category="chapter") self.sequential = ItemFactory.create(parent=self.chapter, category='sequential') + self.vertical = ItemFactory.create(parent=self.sequential, category="vertical") CourseEnrollmentFactory(user=self.user, course_id=self.course.id) @@ -2775,7 +2777,12 @@ class TestIndexViewWithCourseDurationLimits(ModuleStoreTestCase): ) ) bannerText = get_expiration_banner_text(self.user, self.course) - self.assertContains(response, bannerText, html=True) + # Banner is XBlock wrapper, so it is escaped in raw response. Since + # it's escaped, ignoring the whitespace with assertContains doesn't + # work. Instead we remove all whitespace to verify content is correct. + bannerText_no_spaces = escape(bannerText).replace(' ', '') + response_no_spaces = response.content.decode('utf-8').replace(' ', '') + self.assertIn(bannerText_no_spaces, response_no_spaces) def test_index_without_course_duration_limits(self): """ diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 40b0bbf914..a961742b3e 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -35,7 +35,6 @@ from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.core.djangoapps.util.user_messages import PageLevelMessages from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace from openedx.core.djangolib.markup import HTML, Text -from openedx.features.course_duration_limits.access import register_course_expired_message from openedx.features.course_experience import ( COURSE_OUTLINE_PAGE_FLAG, default_course_url_name, COURSE_ENABLE_UNENROLLED_ACCESS_FLAG ) @@ -146,7 +145,6 @@ class CoursewareIndex(View): self.is_staff = has_access(request.user, 'staff', self.course) self._setup_masquerade_for_effective_user() - register_course_expired_message(request, self.course) return self.render(request) except Exception as exception: # pylint: disable=broad-except diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 7460783b41..597f53e7d4 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -86,7 +86,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.util.user_messages import PageLevelMessages from openedx.core.djangolib.markup import HTML, Text -from openedx.features.course_duration_limits.access import register_course_expired_message +from openedx.features.course_duration_limits.access import generate_course_expired_fragment from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, course_home_url_name from openedx.features.course_experience.course_tools import CourseToolsPluginManager from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView @@ -505,7 +505,6 @@ class CourseTabView(EdxFragmentView): # Show warnings if the user has limited access # Must come after masquerading on creation of page context self.register_user_access_warning_messages(request, course_key) - register_course_expired_message(request, course) set_custom_metrics_for_course_key(course_key) return super(CourseTabView, self).get(request, course=course, page_context=page_context, **kwargs) @@ -984,7 +983,7 @@ def _progress(request, course_key, student_id): # checking certificate generation configuration enrollment_mode, _ = CourseEnrollment.enrollment_mode_for_user(student, course_key) - register_course_expired_message(request, course) + course_expiration_fragment = generate_course_expired_fragment(student, course) context = { 'course': course, @@ -996,6 +995,7 @@ def _progress(request, course_key, student_id): 'supports_preview_menu': True, 'student': student, 'credit_course_requirements': _credit_course_requirements(course_key, student), + 'course_expiration_fragment': course_expiration_fragment, } if certs_api.get_active_web_certificate(course): context['certificate_data'] = _get_cert_data(student, course, enrollment_mode, course_grade) diff --git a/lms/djangoapps/discussion/templates/discussion/discussion_board_fragment.html b/lms/djangoapps/discussion/templates/discussion/discussion_board_fragment.html index 0adb14423d..824d0c5bb5 100644 --- a/lms/djangoapps/discussion/templates/discussion/discussion_board_fragment.html +++ b/lms/djangoapps/discussion/templates/discussion/discussion_board_fragment.html @@ -41,6 +41,9 @@ from openedx.core.djangolib.markup import HTML + % if course_expiration_fragment: + ${HTML(course_expiration_fragment.content)} + % endif
${_("Course Progress for Student '{username}' ({email})").format(username=username, email=student.email)} - + % if course_expiration_fragment: + ${HTML(course_expiration_fragment.content)} + % endif
<%block name="certificate_block"> diff --git a/openedx/features/course_duration_limits/access.py b/openedx/features/course_duration_limits/access.py index 64a9e65108..5bee1a6929 100644 --- a/openedx/features/course_duration_limits/access.py +++ b/openedx/features/course_duration_limits/access.py @@ -9,7 +9,7 @@ from django.utils import timezone from django.utils.translation import get_language, ugettext as _ from student.models import CourseEnrollment -from util.date_utils import DEFAULT_SHORT_DATE_FORMAT, strftime_localized +from util.date_utils import strftime_localized from course_modes.models import CourseMode from lms.djangoapps.courseware.access_response import AccessError @@ -18,9 +18,9 @@ from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_lin from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_specific_student from openedx.core.djangoapps.catalog.utils import get_course_run_details from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.util.user_messages import PageLevelMessages from openedx.core.djangolib.markup import HTML, Text from openedx.features.course_duration_limits.models import CourseDurationLimitConfig +from web_fragments.fragment import Fragment MIN_DURATION = timedelta(weeks=4) MAX_DURATION = timedelta(weeks=12) @@ -112,28 +112,25 @@ def check_course_expired(user, course): return ACCESS_GRANTED -def register_course_expired_message(request, course): +def generate_course_expired_message(user, course): """ - Add a banner notifying the user of the user course expiration date if it exists. + Generate the message for the user course expiration date if it exists. """ - if not CourseDurationLimitConfig.enabled_for_enrollment(user=request.user, course_key=course.id): + if not CourseDurationLimitConfig.enabled_for_enrollment(user=user, course_key=course.id): return - expiration_date = get_user_course_expiration_date(request.user, course) + expiration_date = get_user_course_expiration_date(user, course) if not expiration_date: return - if is_masquerading_as_specific_student(request.user, course.id) and timezone.now() > expiration_date: + if is_masquerading_as_specific_student(user, course.id) and timezone.now() > expiration_date: upgrade_message = _('This learner does not have access to this course. ' 'Their access expired on {expiration_date}.') - PageLevelMessages.register_warning_message( - request, - HTML(upgrade_message).format( - expiration_date=strftime_localized(expiration_date, '%b. %-d, %Y') - ) + return HTML(upgrade_message).format( + expiration_date=strftime_localized(expiration_date, '%b. %-d, %Y') ) else: - enrollment = CourseEnrollment.get_enrollment(request.user, course.id) + enrollment = CourseEnrollment.get_enrollment(user, course.id) if enrollment is None: return @@ -169,30 +166,58 @@ def register_course_expired_message(request, course): else: formatted_upgrade_deadline = strftime_localized(upgrade_deadline, '%b. %-d, %Y') - PageLevelMessages.register_info_message( - request, - Text(full_message).format( - a_open=HTML('').format( - upgrade_link=verified_upgrade_deadline_link(user=request.user, course=course) - ), - sronly_span_open=HTML(''), - span_close=HTML(''), - a_close=HTML(''), - expiration_date=formatted_expiration_date, - strong_open=HTML(''), - strong_close=HTML(''), - line_break=HTML('
'), - upgrade_deadline=formatted_upgrade_deadline - ) + return HTML(full_message).format( + a_open=HTML('').format( + upgrade_link=verified_upgrade_deadline_link(user=user, course=course) + ), + sronly_span_open=HTML(''), + span_close=HTML(''), + a_close=HTML(''), + expiration_date=formatted_expiration_date, + strong_open=HTML(''), + strong_close=HTML(''), + line_break=HTML('
'), + upgrade_deadline=formatted_upgrade_deadline ) + else: - PageLevelMessages.register_info_message( - request, - Text(full_message).format( - span_close=HTML(''), - expiration_date=formatted_expiration_date, - strong_open=HTML(''), - strong_close=HTML(''), - line_break=HTML('
'), - ) + return HTML(full_message).format( + span_close=HTML(''), + expiration_date=formatted_expiration_date, + strong_open=HTML(''), + strong_close=HTML(''), + line_break=HTML('
'), ) + + +def generate_course_expired_fragment(user, course): + message = generate_course_expired_message(user, course) + if message: + return Fragment(HTML(u"""\ +
{}
+ """).format(message)) + + +def course_expiration_wrapper(user, block, view, frag, context): # pylint: disable=W0613 + """ + An XBlock wrapper that prepends a message to the beginning of a vertical if + a user's course is about to expire. + """ + if block.category != "vertical": + return frag + + course = CourseOverview.get_from_id(block.course_id) + course_expiration_fragment = generate_course_expired_fragment(user, course) + + if not course_expiration_fragment: + return frag + + # Course content must be escaped to render correctly due to the way the + # way the XBlock rendering works. Transforming the safe markup to unicode + # escapes correctly. + course_expiration_fragment.content = unicode(course_expiration_fragment.content) + + course_expiration_fragment.add_content(frag.content) + course_expiration_fragment.add_fragment_resources(frag) + + return course_expiration_fragment diff --git a/openedx/features/course_duration_limits/tests/test_access.py b/openedx/features/course_duration_limits/tests/test_access.py index a57833ce71..5ae99f30d4 100644 --- a/openedx/features/course_duration_limits/tests/test_access.py +++ b/openedx/features/course_duration_limits/tests/test_access.py @@ -12,7 +12,7 @@ from mock import patch from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.features.course_duration_limits.access import ( - register_course_expired_message, + generate_course_expired_message, get_user_course_expiration_date, ) from openedx.features.course_duration_limits.models import CourseDurationLimitConfig @@ -31,7 +31,6 @@ class TestAccess(CacheIsolationTestCase): CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=UTC)) DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) - @patch('openedx.features.course_duration_limits.access.PageLevelMessages') @ddt.data( *itertools.product( ['en-us', 'es-419'], @@ -39,7 +38,7 @@ class TestAccess(CacheIsolationTestCase): ) ) @ddt.unpack - def test_register_course_expired_message(self, language, offsets, mock_messages): + def test_generate_course_expired_message(self, language, offsets): now = timezone.now() schedule_offset, course_offset = offsets @@ -78,19 +77,11 @@ class TestAccess(CacheIsolationTestCase): enrollment=enrollment, upgrade_deadline=schedule_upgrade_deadline, ) - request = RequestFactory().get('/courseware') - request.user = enrollment.user duration_limit_upgrade_deadline = get_user_course_expiration_date(enrollment.user, enrollment.course) self.assertIsNotNone(duration_limit_upgrade_deadline) - register_course_expired_message(request, enrollment.course) - self.assertEqual( - mock_messages.register_info_message.call_count, - 1 - ) - - message = str(mock_messages.register_info_message.call_args[0][1]) + message = generate_course_expired_message(enrollment.user, enrollment.course) self.assertIn(format_date(duration_limit_upgrade_deadline), message) diff --git a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html index e99d9f7d78..c6dee98b27 100644 --- a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html @@ -81,6 +81,9 @@ from openedx.features.portfolio_project import INCLUDE_PORTFOLIO_UPSELL_MODAL
+ % if course_expiration_fragment: + ${HTML(course_expiration_fragment.content)} + % endif % if course_home_message_fragment: ${HTML(course_home_message_fragment.body_html())} % endif 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 c17cb36311..048ebcf8c4 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -64,7 +64,7 @@ TEST_PASSWORD = 'test' TEST_CHAPTER_NAME = 'Test Chapter' TEST_COURSE_TOOLS = 'Course Tools' TEST_COURSE_TODAY = 'Today is' -TEST_BANNER_CLASS = '
' +TEST_BANNER_CLASS = '
' TEST_WELCOME_MESSAGE = '

Welcome!

' TEST_UPDATE_MESSAGE = '

Test Update!

' TEST_COURSE_UPDATES_TOOL = '/course/updates">' @@ -688,7 +688,6 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): response = self.client.get(url) bannerText = get_expiration_banner_text(user, self.course) self.assertContains(response, bannerText, html=True) - self.assertContains(response, TEST_BANNER_CLASS) # Verify that enrolled users are not shown the course expiration banner if content gating is disabled config.enabled = False 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 51defb96ef..e48984e821 100644 --- a/openedx/features/course_experience/tests/views/test_course_updates.py +++ b/openedx/features/course_experience/tests/views/test_course_updates.py @@ -130,7 +130,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase): # Fetch the view and verify that the query counts haven't changed # TODO: decrease query count as part of REVO-28 - with self.assertNumQueries(56, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): + with self.assertNumQueries(53, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with check_mongo_calls(4): url = course_updates_url(self.course) self.client.get(url) diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py index a56679de8d..5c1449c3f8 100644 --- a/openedx/features/course_experience/views/course_home.py +++ b/openedx/features/course_experience/views/course_home.py @@ -26,6 +26,7 @@ from lms.djangoapps.courseware.views.views import CourseTabView from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from openedx.core.djangoapps.util.maintenance_banner import add_maintenance_banner from openedx.features.course_experience.course_tools import CourseToolsPluginManager +from openedx.features.course_duration_limits.access import generate_course_expired_fragment from student.models import CourseEnrollment from util.views import ensure_valid_course_key from xmodule.course_module import COURSE_VISIBILITY_PUBLIC_OUTLINE, COURSE_VISIBILITY_PUBLIC @@ -132,6 +133,7 @@ class CourseHomeFragmentView(EdxFragmentView): outline_fragment = None update_message_fragment = None course_sock_fragment = None + course_expiration_fragment = None has_visited_course = None resume_course_url = None handouts_html = None @@ -151,6 +153,7 @@ class CourseHomeFragmentView(EdxFragmentView): course_sock_fragment = CourseSockFragmentView().render_to_fragment(request, course=course, **kwargs) has_visited_course, resume_course_url = self._get_resume_course_info(request, course_id) handouts_html = self._get_course_handouts(request, course) + course_expiration_fragment = generate_course_expired_fragment(request.user, course) elif allow_public_outline or allow_public: outline_fragment = CourseOutlineFragmentView().render_to_fragment( request, course_id=course_id, user_is_enrolled=False, **kwargs @@ -198,6 +201,7 @@ class CourseHomeFragmentView(EdxFragmentView): 'outline_fragment': outline_fragment, 'handouts_html': handouts_html, 'course_home_message_fragment': course_home_message_fragment, + 'course_expiration_fragment': course_expiration_fragment, 'has_visited_course': has_visited_course, 'resume_course_url': resume_course_url, 'course_tools': course_tools,