Commit 482f7652 by Andy Armstrong Committed by GitHub

Merge pull request #15064 from edx/andya/welcome-message

Show a welcome message on the course home page
parents 544d5d59 64de4432
......@@ -446,18 +446,24 @@ class CourseInfoModule(CourseInfoFields, HtmlModuleMixin):
return self.data.replace("%%USER_ID%%", self.system.anonymous_student_id)
return self.data
else:
course_updates = [item for item in self.items if item.get('status') == self.STATUS_VISIBLE]
course_updates.sort(
key=lambda item: (CourseInfoModule.safe_parse_date(item['date']), item['id']),
reverse=True
)
course_updates = self.ordered_updates()
context = {
'visible_updates': course_updates[:3],
'hidden_updates': course_updates[3:],
}
return self.system.render_template("{0}/course_updates.html".format(self.TEMPLATE_DIR), context)
def ordered_updates(self):
"""
Returns any course updates in reverse chronological order.
"""
course_updates = [item for item in self.items if item.get('status') == self.STATUS_VISIBLE]
course_updates.sort(
key=lambda item: (CourseInfoModule.safe_parse_date(item['date']), item['id']),
reverse=True
)
return course_updates
@staticmethod
def safe_parse_date(date):
"""
......
......@@ -235,6 +235,13 @@ def get_course_about_section(request, course, section_key):
raise KeyError("Invalid about key " + str(section_key))
def get_course_info_usage_key(course, section_key):
"""
Returns the usage key for the specified section's course info module.
"""
return course.id.make_usage_key('course_info', section_key)
def get_course_info_section_module(request, user, course, section_key):
"""
This returns the course info module for a given section_key.
......@@ -245,7 +252,7 @@ def get_course_info_section_module(request, user, course, section_key):
- updates
- guest_updates
"""
usage_key = course.id.make_usage_key('course_info', section_key)
usage_key = get_course_info_usage_key(course, section_key)
# Use an empty cache
field_data_cache = FieldDataCache([], course.id, user)
......
......@@ -10,7 +10,7 @@ from django.utils.translation import ugettext as _, ugettext_noop
from courseware.access import has_access
from courseware.entrance_exams import user_can_skip_entrance_exam
from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.features.course_experience import defaut_course_url_name, UNIFIED_COURSE_EXPERIENCE_FLAG
from openedx.features.course_experience import default_course_url_name, UNIFIED_COURSE_EXPERIENCE_FLAG
from request_cache.middleware import RequestCache
from student.models import CourseEnrollment
from xmodule.tabs import CourseTab, CourseTabList, key_checker, link_reverse_func
......@@ -45,7 +45,7 @@ class CoursewareTab(EnrolledTab):
Returns a function that computes the URL for this tab.
"""
request = RequestCache.get_current_request()
url_name = defaut_course_url_name(request)
url_name = default_course_url_name(request)
return link_reverse_func(url_name)
......
......@@ -428,7 +428,7 @@ class StaticCourseTabView(EdxFragmentView):
"""
return get_static_tab_fragment(request, course, tab)
def render_to_standalone_html(self, request, fragment, course=None, tab=None, **kwargs):
def render_standalone_response(self, request, fragment, course=None, tab=None, **kwargs):
"""
Renders this static tab's fragment to HTML for a standalone page.
"""
......@@ -531,14 +531,14 @@ class CourseTabView(EdxFragmentView):
tab = page_context['tab']
return tab.render_to_fragment(request, course, **kwargs)
def render_to_standalone_html(self, request, fragment, course=None, tab=None, page_context=None, **kwargs):
def render_standalone_response(self, request, fragment, course=None, tab=None, page_context=None, **kwargs):
"""
Renders this course tab's fragment to HTML for a standalone page.
"""
if not page_context:
page_context = self.create_page_context(request, course=course, tab=tab, **kwargs)
page_context['fragment'] = fragment
return render_to_string('courseware/tab-view.html', page_context)
return render_to_response('courseware/tab-view.html', page_context)
@ensure_csrf_cookie
......
// Welcome message
.welcome-message {
border: solid 1px $lms-border-color;
@include border-left(solid 4px $black);
margin-bottom: $baseline;
padding: $baseline;
h1, h2, h3 {
font-size: font-size(large);
font-weight: bold;
color: $black;
}
}
// Course sidebar
.course-sidebar {
@include margin-left(0);
......
......@@ -2,11 +2,11 @@
Views for building plugins.
"""
from abc import abstractmethod
import logging
from django.conf import settings
from django.contrib.staticfiles.storage import staticfiles_storage
from django.http import HttpResponse
from django.shortcuts import render_to_response
from web_fragments.views import FragmentView
......@@ -78,10 +78,14 @@ class EdxFragmentView(FragmentView):
for js_file in self.js_dependencies():
fragment.add_javascript_url(staticfiles_storage.url(js_file))
def render_to_standalone_html(self, request, fragment, **kwargs):
def render_standalone_response(self, request, fragment, **kwargs):
"""
Renders this fragment to HTML for a standalone page.
Renders a standalone page for the specified fragment.
Note: if fragment is None, a 204 response will be returned (no content).
"""
if fragment is None:
return HttpResponse(status=204)
context = {
'uses-pattern-library': self.USES_PATTERN_LIBRARY,
'settings': settings,
......
......@@ -15,7 +15,7 @@ from django.views.generic import View
from courseware.courses import get_course_with_access
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.features.course_experience import defaut_course_url_name
from openedx.features.course_experience import default_course_url_name
from util.views import ensure_valid_course_key
from web_fragments.fragment import Fragment
......@@ -38,7 +38,7 @@ class CourseBookmarksView(View):
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
course_url_name = defaut_course_url_name(request)
course_url_name = default_course_url_name(request)
course_url = reverse(course_url_name, kwargs={'course_id': unicode(course.id)})
# Render the bookmarks list as a fragment
......
......@@ -14,7 +14,7 @@ UNIFIED_COURSE_EXPERIENCE_FLAG = 'unified_course_experience'
UNIFIED_COURSE_VIEW_FLAG = 'unified_course_view'
def defaut_course_url_name(request=None):
def default_course_url_name(request=None):
"""
Returns the default course URL name for the current user.
"""
......
......@@ -58,6 +58,12 @@ from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
<div class="page-content">
<div class="layout layout-1q3q">
<main class="layout-col layout-col-b">
% if welcome_message_fragment and waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG):
<div class="section section-dates">
${HTML(welcome_message_fragment.body_html())}
</div>
% endif
${HTML(outline_fragment.body_html())}
</main>
<aside class="course-sidebar layout-col layout-col-a">
......
## mako
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from openedx.core.djangolib.markup import HTML
%>
<%block name="content">
<div class="welcome-message">
${HTML(welcome_message_html)}
</div>
</%block>
......@@ -6,7 +6,6 @@ from waffle.testutils import override_flag
from django.core.urlresolvers import reverse
from openedx.core.djangoapps.content.block_structure.api import get_course_in_cache
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore import ModuleStoreEnum
......@@ -15,7 +14,10 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, chec
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
from .test_course_updates import create_course_update, remove_course_updates
TEST_PASSWORD = 'test'
TEST_WELCOME_MESSAGE = '<h2>Welcome!</h2>'
def course_home_url(course):
......@@ -55,6 +57,9 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
cls.user = UserFactory(password=TEST_PASSWORD)
CourseEnrollment.enroll(cls.user, cls.course.id)
# Create a welcome message
create_course_update(cls.course, cls.user, TEST_WELCOME_MESSAGE)
def setUp(self):
"""
Set up for the tests.
......@@ -62,6 +67,10 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
super(TestCourseHomePage, self).setUp()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
def tearDown(self):
remove_course_updates(self.course)
super(TestCourseHomePage, self).tearDown()
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
def test_unified_page(self):
"""
......@@ -71,15 +80,27 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
response = self.client.get(url)
self.assertContains(response, '<h2 class="hd hd-3 page-title">Test Course</h2>')
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
def test_welcome_message_when_unified(self):
url = course_home_url(self.course)
response = self.client.get(url)
self.assertContains(response, TEST_WELCOME_MESSAGE, status_code=200)
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=False)
def test_welcome_message_when_not_unified(self):
url = course_home_url(self.course)
response = self.client.get(url)
self.assertNotContains(response, TEST_WELCOME_MESSAGE, status_code=200)
def test_queries(self):
"""
Verify that the view's query count doesn't regress.
"""
# Pre-fill the course blocks cache
get_course_in_cache(self.course.id)
# Pre-fetch the view to populate any caches
course_home_url(self.course)
# Fetch the view and verify the query counts
with self.assertNumQueries(35):
with check_mongo_calls(3):
with self.assertNumQueries(43):
with check_mongo_calls(5):
url = course_home_url(self.course)
self.client.get(url)
......@@ -3,11 +3,13 @@ Tests for the course updates page.
"""
from django.core.urlresolvers import reverse
from courseware.courses import get_course_info_section_module, get_course_info_usage_key
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.html_module import CourseInfoModule
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
......@@ -26,6 +28,51 @@ def course_updates_url(course):
)
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": CourseInfoModule.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(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)
course_updates.items = []
except ItemNotFoundError:
pass
class TestCourseUpdatesPage(SharedModuleStoreTestCase):
"""
Test the course updates page.
......@@ -50,24 +97,6 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
cls.user = UserFactory(password=TEST_PASSWORD)
CourseEnrollment.enroll(cls.user, cls.course.id)
# Create course updates
cls.create_course_updates(cls.course, cls.user)
@classmethod
def create_course_updates(cls, course, user, count=5):
"""
Create some test course updates.
"""
updates_usage_key = course.id.make_usage_key('course_info', '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 = u'<ol><li><a href="test">Test Update</a></li></ol>'
modulestore().update_item(course_updates, user.id)
def setUp(self):
"""
Set up for the tests.
......@@ -75,14 +104,25 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
super(TestCourseUpdatesPage, self).setUp()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
def tearDown(self):
remove_course_updates(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')
url = course_updates_url(self.course)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_content = response.content.decode("utf-8")
self.assertIn('<a href="test">Test Update</a>', response_content)
self.assertContains(response, 'First Message')
self.assertContains(response, 'Second Message')
def test_queries(self):
create_course_update(self.course, self.user, 'First Message')
# Pre-fetch the view to populate any caches
course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed
with self.assertNumQueries(32):
with check_mongo_calls(4):
......
"""
Tests for course welcome messages.
"""
from django.core.urlresolvers import reverse
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
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>'
def welcome_message_url(course):
"""
Returns the URL for the welcome message view.
"""
return reverse(
'openedx.course_experience.welcome_message_fragment_view',
kwargs={
'course_id': unicode(course.id),
}
)
class TestWelcomeMessageView(SharedModuleStoreTestCase):
"""
Tests for the course welcome message fragment view.
"""
@classmethod
def setUpClass(cls):
"""Set up the simplest course possible."""
# setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase
# pylint: disable=super-method-not-called
with super(TestWelcomeMessageView, 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):
super(TestWelcomeMessageView, self).setUp()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
def tearDown(self):
remove_course_updates(self.course)
super(TestWelcomeMessageView, self).tearDown()
def test_welcome_message(self):
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')
response = self.client.get(welcome_message_url(self.course))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Second Update')
def test_empty_welcome_message(self):
response = self.client.get(welcome_message_url(self.course))
self.assertEqual(response.status_code, 204)
......@@ -7,6 +7,7 @@ from django.conf.urls import url
from views.course_home import CourseHomeView, CourseHomeFragmentView
from views.course_outline import CourseOutlineFragmentView
from views.course_updates import CourseUpdatesFragmentView, CourseUpdatesView
from views.welcome_message import WelcomeMessageFragmentView
urlpatterns = [
url(
......@@ -34,4 +35,9 @@ urlpatterns = [
CourseUpdatesFragmentView.as_view(),
name='openedx.course_experience.course_updates_fragment_view',
),
url(
r'^welcome_message_fragment$',
WelcomeMessageFragmentView.as_view(),
name='openedx.course_experience.welcome_message_fragment_view',
),
]
......@@ -18,6 +18,7 @@ from web_fragments.fragment import Fragment
from .course_outline import CourseOutlineFragmentView
from .course_dates import CourseDatesFragmentView
from .welcome_message import WelcomeMessageFragmentView
from ..utils import get_course_outline_block_tree
......@@ -93,6 +94,11 @@ class CourseHomeFragmentView(EdxFragmentView):
# Get resume course information
has_visited_course, resume_course_url = self._get_resume_course_info(request, course_id)
# Render the welcome message as a fragment
welcome_message_fragment = WelcomeMessageFragmentView().render_to_fragment(
request, course_id=course_id, **kwargs
)
# Render the course dates as a fragment
dates_fragment = CourseDatesFragmentView().render_to_fragment(request, course_id=course_id, **kwargs)
......@@ -113,6 +119,7 @@ class CourseHomeFragmentView(EdxFragmentView):
'has_visited_course': has_visited_course,
'resume_course_url': resume_course_url,
'dates_fragment': dates_fragment,
'welcome_message_fragment': welcome_message_fragment,
'disable_courseware_js': True,
'uses_pattern_library': True,
}
......
......@@ -13,7 +13,7 @@ from courseware.courses import get_course_info_section, get_course_with_access
from lms.djangoapps.courseware.views.views import CourseTabView
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.features.course_experience import defaut_course_url_name
from openedx.features.course_experience import default_course_url_name
from web_fragments.fragment import Fragment
......@@ -45,7 +45,7 @@ class CourseUpdatesFragmentView(EdxFragmentView):
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
course_url_name = defaut_course_url_name(request)
course_url_name = default_course_url_name(request)
course_url = reverse(course_url_name, kwargs={'course_id': unicode(course.id)})
# Fetch the updates as HTML
......
"""
View logic for handling course welcome messages.
"""
from django.template.loader import render_to_string
from courseware.courses import get_course_info_section_module, get_course_with_access
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from web_fragments.fragment import Fragment
class WelcomeMessageFragmentView(EdxFragmentView):
"""
A fragment that displays a course's welcome message.
"""
def render_to_fragment(self, request, course_id=None, **kwargs):
"""
Renders the welcome message fragment for the specified course.
Returns: A fragment, or None if there is no welcome message.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
welcome_message_html = self.welcome_message_html(request, course)
if not welcome_message_html:
return None
context = {
'welcome_message_html': welcome_message_html,
}
html = render_to_string('course_experience/welcome-message-fragment.html', context)
return Fragment(html)
@classmethod
def welcome_message_html(cls, request, course):
"""
Returns the course's welcome message or None if it doesn't have one.
"""
info_module = get_course_info_section_module(request, request.user, course, 'updates')
if not info_module:
return None
# Return the course update with the most recent publish date
info_block = getattr(info_module, '_xmodule', info_module)
ordered_updates = info_block.ordered_updates()
return ordered_updates[0]['content'] if ordered_updates else None
......@@ -210,7 +210,7 @@ py2neo==3.1.2
-r coverage.txt
# Support for plugins
web-fragments==0.2.1
web-fragments==0.2.2
xblock==0.5.0
# Third Party XBlocks
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment