-
+
-
+ ++ +
Testing
+
+
${_('Course details')}
diff --git a/lms/templates/dates_banner.html b/lms/templates/dates_banner.html
new file mode 100644
index 0000000000..594fb7991c
--- /dev/null
+++ b/lms/templates/dates_banner.html
@@ -0,0 +1,108 @@
+## mako
+
+<%page expression_filter="h"/>
+<%!
+from django.utils.translation import ugettext as _
+
+from lms.djangoapps.courseware.date_summary import CourseAssignmentDate
+from common.djangoapps.course_modes.models import CourseMode
+%>
+
+<%
+additional_styling_class = 'on-mobile' if is_mobile_app else 'has-button'
+%>
+
+<%def name="reset_dates_banner()">
+
+
+
diff --git a/openedx/features/course_experience/static/course_experience/fixtures/enrollment-button.html b/openedx/features/course_experience/static/course_experience/fixtures/enrollment-button.html
new file mode 100644
index 0000000000..1e55c18cb7
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/fixtures/enrollment-button.html
@@ -0,0 +1 @@
+
diff --git a/openedx/features/course_experience/static/course_experience/fixtures/latest-update-fragment.html b/openedx/features/course_experience/static/course_experience/fixtures/latest-update-fragment.html
new file mode 100644
index 0000000000..41fd8b44c3
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/fixtures/latest-update-fragment.html
@@ -0,0 +1,10 @@
+
+ <%include file="/dates_banner.html" />
+ % if course_sections is not None:
+
+
+
+<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
+ DateUtilFactory.transform('.localized-datetime');
+%static:require_module_async>
+
+<%static:webpack entry="CourseOutline">
+ new CourseOutline();
+%static:webpack>
diff --git a/openedx/features/course_experience/templates/course_experience/latest-update-fragment.html b/openedx/features/course_experience/templates/course_experience/latest-update-fragment.html
new file mode 100644
index 0000000000..4f9b6082b9
--- /dev/null
+++ b/openedx/features/course_experience/templates/course_experience/latest-update-fragment.html
@@ -0,0 +1,27 @@
+## mako
+
+<%page expression_filter="h"/>
+<%namespace name='static' file='../static_content.html'/>
+
+<%!
+from django.utils.translation import ugettext as _
+from openedx.core.djangolib.markup import HTML
+%>
+
+<%block name="content">
+
+
+%def>
+<%def name="upgrade_to_reset_banner()">
+
+ % if is_mobile_app:
+ ${_('It looks like you missed some important deadlines based on our suggested schedule. ')}
+ ${_('To keep yourself on track, you can update this schedule and shift the past due assignments into the future by visiting ')}
+ edx.org.
+ ${_(" Don't worry—you won't lose any of the progress you've made when you shift your due dates.")}
+ % else:
+ ${_('It looks like you missed some important deadlines based on our suggested schedule.')}
+ ${_("To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don't worry—you won't lose any of the progress you've made when you shift your due dates.")}
+ % endif
+
+ % if not is_mobile_app:
+
+
+
+ % endif
+
+
+%def>
+<%def name="upgrade_to_complete_graded_banner()">
+
+ % if is_mobile_app:
+ ${_('You are auditing this course,')}
+ ${_(' which means that you are unable to participate in graded assignments.')}
+ ${_(' It looks like you missed some important deadlines based on our suggested schedule. Graded assignments and schedule adjustment are available to Verified Track learners.')}
+ % else:
+ ${_('You are auditing this course,')}
+ ${_(' which means that you are unable to participate in graded assignments.')}
+ ${_(' It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')}
+ % endif
+
+ % if not is_mobile_app:
+
+
+
+
+
+ % endif
+
+
+%def>
+
+% if not has_ended:
+ % if on_dates_tab and not missed_deadlines:
+ %if getattr(course, 'self_paced', False):
+
+ % if is_mobile_app:
+ ${_('You are auditing this course,')}
+ ${_(' which means that you are unable to participate in graded assignments.')}
+ ${_('Graded assignments are available to Verified Track learners.')}
+ % else:
+ ${_('You are auditing this course,')}
+ ${_(' which means that you are unable to participate in graded assignments.')}
+ ${_(' To complete graded assignments as part of this course, you can upgrade today.')}
+ % endif
+
+ % if not is_mobile_app:
+
+
+
+
+
+ % endif
+
+
+ % endif
+ % if content_type_gating_enabled:
+ ${upgrade_to_complete_graded_banner()}
+ % endif
+ % elif missed_deadlines:
+ % if missed_gated_content:
+ ${upgrade_to_reset_banner()}
+ % else:
+ ${reset_dates_banner()}
+ % endif
+ % endif
+% endif
diff --git a/lms/urls.py b/lms/urls.py
index a985dec640..773a3e8b80 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -667,6 +667,14 @@ urlpatterns += [
include('openedx.features.calendar_sync.urls'),
),
+ # Course search
+ re_path(
+ r'^courses/{}/search/'.format(
+ settings.COURSE_ID_PATTERN,
+ ),
+ include('openedx.features.course_search.urls'),
+ ),
+
# Learner profile
path(
'u/',
@@ -801,6 +809,12 @@ if configuration_helpers.get_value('ENABLE_BULK_ENROLLMENT_VIEW', settings.FEATU
path('api/bulk_enroll/v1/', include('lms.djangoapps.bulk_enroll.urls')),
]
+# Course goals
+urlpatterns += [
+ path('api/course_goals/', include(('lms.djangoapps.course_goals.urls', 'lms.djangoapps.course_goals'),
+ namespace='course_goals_api')),
+]
+
# Embargo
if settings.FEATURES.get('EMBARGO'):
urlpatterns += [
diff --git a/openedx/core/djangoapps/embargo/tests/test_middleware.py b/openedx/core/djangoapps/embargo/tests/test_middleware.py
index bed219bb50..d6a274b2de 100644
--- a/openedx/core/djangoapps/embargo/tests/test_middleware.py
+++ b/openedx/core/djangoapps/embargo/tests/test_middleware.py
@@ -9,12 +9,14 @@ from config_models.models import cache as config_cache
from django.conf import settings
from django.core.cache import cache as django_cache
from django.urls import reverse
+from edx_toggles.toggles.testutils import override_waffle_flag
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.util.testing import UrlResetMixin
+from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from ..models import IPFilter, RestrictedCourse
from ..test_utils import restrict_course
@@ -22,6 +24,7 @@ from ..test_utils import restrict_course
@ddt.ddt
@skip_unless_lms
+@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class EmbargoMiddlewareAccessTests(UrlResetMixin, ModuleStoreTestCase):
"""Tests of embargo middleware country access rules.
@@ -42,7 +45,10 @@ class EmbargoMiddlewareAccessTests(UrlResetMixin, ModuleStoreTestCase):
self.course = CourseFactory.create()
self.client.login(username=self.USERNAME, password=self.PASSWORD)
- self.courseware_url = reverse('about_course', kwargs={'course_id': str(self.course.id)})
+ self.courseware_url = reverse(
+ 'openedx.course_experience.course_home',
+ kwargs={'course_id': str(self.course.id)}
+ )
self.non_courseware_url = reverse('dashboard')
# Clear the cache to avoid interference between tests
diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py
index 1b406a6d71..caf8bda729 100644
--- a/openedx/core/djangoapps/schedules/resolvers.py
+++ b/openedx/core/djangoapps/schedules/resolvers.py
@@ -28,7 +28,7 @@ from openedx.core.djangoapps.schedules.models import Schedule, ScheduleExperienc
from openedx.core.djangoapps.schedules.utils import PrefixedDebugLoggerMixin
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from openedx.core.djangolib.translation_utils import translate_date
-from openedx.features.course_experience import course_home_url
+from openedx.features.course_experience import course_home_url_name
LOG = logging.getLogger(__name__)
@@ -542,8 +542,9 @@ def _get_trackable_course_home_url(course_id):
Args:
course_id (CourseKey): The course to get the home page URL for.
-U
+
Returns:
- A URL to the course home page.
+ A relative path to the course home page.
"""
- return course_home_url(course_id)
+ course_url_name = course_home_url_name(course_id)
+ return reverse(course_url_name, args=[str(course_id)])
diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py
index 4e177491f4..1e63a02d0a 100644
--- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py
+++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py
@@ -167,7 +167,7 @@ class TestCourseUpdateResolver(SchedulesResolverTestMixin, ModuleStoreTestCase):
'contact_mailing_address': '123 Sesame Street',
'course_ids': [str(self.course.id)],
'course_name': self.course.display_name,
- 'course_url': f'http://learning-mfe/course/{self.course.id}/home',
+ 'course_url': f'/courses/{self.course.id}/course/',
'dashboard_url': '/dashboard',
'homepage_url': '/',
'mobile_store_urls': {},
@@ -258,7 +258,7 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor
'contact_mailing_address': '123 Sesame Street',
'course_ids': [str(self.course.id)],
'course_name': self.course.display_name,
- 'course_url': f'http://learning-mfe/course/{self.course.id}/home',
+ 'course_url': f'/courses/{self.course.id}/course/',
'dashboard_url': '/dashboard',
'homepage_url': '/',
'mobile_store_urls': {},
diff --git a/openedx/core/djangoapps/user_authn/views/auto_auth.py b/openedx/core/djangoapps/user_authn/views/auto_auth.py
index 324a0c1959..03b2c30b84 100644
--- a/openedx/core/djangoapps/user_authn/views/auto_auth.py
+++ b/openedx/core/djangoapps/user_authn/views/auto_auth.py
@@ -19,7 +19,7 @@ from opaque_keys.edx.locator import CourseLocator
from lms.djangoapps.verify_student.models import ManualVerification
from openedx.core.djangoapps.django_comment_common.models import assign_role
from openedx.core.djangoapps.user_authn.views.registration_form import AccountCreationForm
-from openedx.features.course_experience import course_home_url
+from openedx.features.course_experience import course_home_url_name
from common.djangoapps.student.helpers import (
AccountValidationError,
authenticate_new_user,
@@ -170,9 +170,9 @@ def auto_auth(request): # pylint: disable=too-many-statements
elif course_id:
# Redirect to the course homepage (in LMS) or outline page (in Studio)
try:
- redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id}) # Studio
+ redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id})
except NoReverseMatch:
- redirect_url = course_home_url(course_key) # LMS
+ redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
else:
# Redirect to the learner dashboard (in LMS) or homepage (in Studio)
try:
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py b/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py
index 0346aee25a..a8bbd8a42a 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py
@@ -206,13 +206,13 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase, ModuleStoreTestCase):
enrollment = CourseEnrollment.objects.get(course_id=course_key)
assert enrollment.user.username == 'test'
- # Check that the redirect was to the correct outline page for either lms or studio
+ # Check that the redirect was to the course info/outline page
if settings.ROOT_URLCONF == 'lms.urls':
- expected_redirect_url = f'http://learning-mfe/course/{course_id}/home'
+ url_pattern = '/course/'
else:
- expected_redirect_url = f'/course/{course_id}'
+ url_pattern = f'/course/{str(course_key)}'
- assert response.url == expected_redirect_url
+ assert response.url.endswith(url_pattern)
def test_redirect_to_main(self):
# Create user and redirect to 'home' (cms) or 'dashboard' (lms)
diff --git a/openedx/features/calendar_sync/views/calendar_sync.py b/openedx/features/calendar_sync/views/calendar_sync.py
index 20f182daff..2238e1d2aa 100644
--- a/openedx/features/calendar_sync/views/calendar_sync.py
+++ b/openedx/features/calendar_sync/views/calendar_sync.py
@@ -8,6 +8,7 @@ import json
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import redirect
+from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View
@@ -21,7 +22,6 @@ from openedx.features.calendar_sync.api import (
subscribe_user_to_calendar,
unsubscribe_user_to_calendar
)
-from openedx.features.course_experience import course_home_url
class CalendarSyncView(View):
@@ -54,4 +54,4 @@ class CalendarSyncView(View):
else:
return HttpResponse('Toggle data was not provided or had unknown value.',
status=status.HTTP_422_UNPROCESSABLE_ENTITY)
- return redirect(course_home_url(course_key))
+ return redirect(reverse('openedx.course_experience.course_home', args=[course_id]))
diff --git a/openedx/features/course_bookmarks/views/course_bookmarks.py b/openedx/features/course_bookmarks/views/course_bookmarks.py
index 7cdab73fc3..00db635d70 100644
--- a/openedx/features/course_bookmarks/views/course_bookmarks.py
+++ b/openedx/features/course_bookmarks/views/course_bookmarks.py
@@ -19,7 +19,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.core.djangoapps.user_api.models import UserPreference
-from openedx.features.course_experience import default_course_url
+from openedx.features.course_experience import default_course_url_name
from common.djangoapps.util.views import ensure_valid_course_key
@@ -41,7 +41,8 @@ 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 = default_course_url(course.id)
+ course_url_name = default_course_url_name(course.id)
+ course_url = reverse(course_url_name, kwargs={'course_id': str(course.id)})
# Render the bookmarks list as a fragment
bookmarks_fragment = CourseBookmarksFragmentView().render_to_fragment(request, course_id=course_id)
diff --git a/openedx/features/course_duration_limits/tests/test_course_expiration.py b/openedx/features/course_duration_limits/tests/test_course_expiration.py
index 8f0211513f..c66734e28d 100644
--- a/openedx/features/course_duration_limits/tests/test_course_expiration.py
+++ b/openedx/features/course_duration_limits/tests/test_course_expiration.py
@@ -10,9 +10,6 @@ from django.conf import settings
from django.urls import reverse
from django.utils.timezone import now
from edx_toggles.toggles.testutils import override_waffle_flag
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
-from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment, FBEEnrollmentExclusion
@@ -24,8 +21,8 @@ from common.djangoapps.student.tests.factories import InstructorFactory
from common.djangoapps.student.tests.factories import OrgInstructorFactory
from common.djangoapps.student.tests.factories import OrgStaffFactory
from common.djangoapps.student.tests.factories import StaffFactory
+from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin
-from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND
from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_date_signals.utils import MAX_DURATION, MIN_DURATION
@@ -40,11 +37,14 @@ from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITIO
from openedx.features.course_duration_limits.access import get_user_course_expiration_date
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_experience.tests.views.helpers import add_course_mode
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID # lint-amnesty, pylint: disable=wrong-import-order
# pylint: disable=no-member
@ddt.ddt
-@override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True)
+@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
"""Tests to verify the get_user_course_expiration_date function is working correctly"""
def setUp(self):
@@ -52,21 +52,6 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.course = CourseFactory(
start=now() - timedelta(weeks=10),
)
- self.chapter = ItemFactory.create(
- category='chapter',
- parent_location=self.course.location,
- display_name='Test Chapter'
- )
- self.sequential = ItemFactory.create(
- category='sequential',
- parent_location=self.chapter.location,
- display_name='Test Sequential'
- )
- ItemFactory.create(
- category='vertical',
- parent_location=self.sequential.location,
- display_name='Test Vertical'
- )
self.user = UserFactory()
self.THREE_YEARS_AGO = now() - timedelta(days=(365 * 3))
@@ -78,18 +63,6 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
CourseEnrollment.unenroll(self.user, self.course.id)
super().tearDown() # lint-amnesty, pylint: disable=super-with-arguments
- def get_courseware(self):
- """Returns a response from a GET on a courseware section"""
- courseware_url = reverse(
- 'courseware_section',
- kwargs={
- 'course_id': str(self.course.id),
- 'chapter': self.chapter.location.block_id,
- 'section': self.sequential.location.block_id,
- },
- )
- return self.client.get(courseware_url, follow=True)
-
def test_enrollment_mode(self):
"""Tests that verified enrollments do not have an expiration"""
CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
@@ -263,7 +236,8 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(**masquerade_config)
- response = self.get_courseware()
+ course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
+ response = self.client.get(course_home_url, follow=True)
assert response.status_code == 200
self.assertCountEqual(response.redirect_chain, [])
banner_text = 'You lose all access to this course, including your progress,'
@@ -299,7 +273,8 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(username='audit')
- response = self.get_courseware()
+ course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
+ response = self.client.get(course_home_url, follow=True)
assert response.status_code == 200
self.assertCountEqual(response.redirect_chain, [])
banner_text = 'You lose all access to this course, including your progress,'
@@ -334,7 +309,8 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(username='audit')
- response = self.get_courseware()
+ course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
+ response = self.client.get(course_home_url, follow=True)
assert response.status_code == 200
self.assertCountEqual(response.redirect_chain, [])
banner_text = 'This learner does not have access to this course. Their access expired on'
@@ -384,7 +360,8 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(username=expired_staff.username)
- response = self.get_courseware()
+ course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
+ response = self.client.get(course_home_url, follow=True)
assert response.status_code == 200
self.assertCountEqual(response.redirect_chain, [])
banner_text = 'This learner does not have access to this course. Their access expired on'
@@ -432,7 +409,8 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(username=expired_staff.username)
- response = self.get_courseware()
+ course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
+ response = self.client.get(course_home_url, follow=True)
assert response.status_code == 200
self.assertCountEqual(response.redirect_chain, [])
banner_text = 'This learner does not have access to this course. Their access expired on'
diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py
index 0c9eca091d..38db72f69e 100644
--- a/openedx/features/course_experience/__init__.py
+++ b/openedx/features/course_experience/__init__.py
@@ -1,24 +1,28 @@
"""
Unified course experience settings and helper methods.
"""
-
-from django.urls import reverse
+import crum
from django.utils.translation import gettext as _
-from edx_toggles.toggles import LegacyWaffleFlag, LegacyWaffleFlagNamespace
-from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
+from edx_django_utils.monitoring import set_custom_attribute
+from waffle import flag_is_active # lint-amnesty, pylint: disable=invalid-django-waffle-import
+from edx_toggles.toggles import LegacyWaffleFlag, LegacyWaffleFlagNamespace
+from openedx.core.djangoapps.util.user_messages import UserMessageCollection
+from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
# Namespace for course experience waffle flags.
WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='course_experience')
+COURSE_EXPERIENCE_WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='course_experience')
+
# Waffle flag to disable the separate course outline page and full width content.
DISABLE_COURSE_OUTLINE_PAGE_FLAG = CourseWaffleFlag( # lint-amnesty, pylint: disable=toggle-missing-annotation
- WAFFLE_FLAG_NAMESPACE, 'disable_course_outline_page', __name__
+ COURSE_EXPERIENCE_WAFFLE_FLAG_NAMESPACE, 'disable_course_outline_page', __name__
)
# Waffle flag to enable a single unified "Course" tab.
DISABLE_UNIFIED_COURSE_TAB_FLAG = CourseWaffleFlag( # lint-amnesty, pylint: disable=toggle-missing-annotation
- WAFFLE_FLAG_NAMESPACE, 'disable_unified_course_tab', __name__
+ COURSE_EXPERIENCE_WAFFLE_FLAG_NAMESPACE, 'disable_unified_course_tab', __name__
)
# Waffle flag to enable the sock on the footer of the home and courseware pages.
@@ -37,6 +41,24 @@ COURSE_PRE_START_ACCESS_FLAG = LegacyWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'pre_star
# .. toggle_warnings: This temporary feature toggle does not have a target removal date.
ENABLE_COURSE_GOALS = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'enable_course_goals', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation
+# Waffle flag to control the display of the hero
+SHOW_UPGRADE_MSG_ON_COURSE_HOME = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_upgrade_msg_on_course_home', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation
+
+# Waffle flag to control the display of the upgrade deadline message
+UPGRADE_DEADLINE_MESSAGE = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'upgrade_deadline_message', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation
+
+# .. toggle_name: course_experience.latest_update
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Used to switch between 'welcome message' and 'latest update' on the course home page.
+# .. toggle_use_cases: opt_out, temporary
+# .. 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. This is no longer used in the learning MFE
+# and can be removed when the outline tab is fully moved to the learning MFE.
+LATEST_UPDATE_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'latest_update', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation
+
# Waffle flag to enable anonymous access to a course
SEO_WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='seo')
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG = CourseWaffleFlag( # lint-amnesty, pylint: disable=toggle-missing-annotation
@@ -72,38 +94,48 @@ RELATIVE_DATES_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'relative_dates',
CALENDAR_SYNC_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'calendar_sync', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation
-def course_home_page_title(_course):
+def course_home_page_title(course): # pylint: disable=unused-argument
"""
Returns the title for the course home page.
"""
return _('Course')
-def default_course_url(course_key):
+def default_course_url_name(course_id):
"""
- Returns the default course URL for the current user.
+ Returns the default course URL name for the current user.
Arguments:
- course_key (CourseKey): The course id of the current course.
+ course_id (CourseKey): The course id of the current course.
"""
- from .url_helpers import get_learning_mfe_home_url
-
- if DISABLE_COURSE_OUTLINE_PAGE_FLAG.is_enabled(course_key):
- return reverse('courseware', args=[str(course_key)])
-
- return get_learning_mfe_home_url(course_key, url_fragment='home')
+ if DISABLE_COURSE_OUTLINE_PAGE_FLAG.is_enabled(course_id):
+ return 'courseware'
+ return 'openedx.course_experience.course_home'
-def course_home_url(course_key):
+def course_home_url_name(course_key):
"""
- Returns the course home page's URL for the current user.
+ Returns the course home page's URL name for the current user.
Arguments:
- course_key (CourseKey): The course key for which the home url is being requested.
- """
- from .url_helpers import get_learning_mfe_home_url
+ course_key (CourseKey): The course key for which the home url is being
+ requested.
+ """
if DISABLE_UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key):
- return reverse('info', args=[str(course_key)])
+ return 'info'
+ return 'openedx.course_experience.course_home'
- return get_learning_mfe_home_url(course_key, url_fragment='home')
+
+class CourseHomeMessages(UserMessageCollection):
+ """
+ This set of messages appear above the outline on the course home page.
+ """
+ NAMESPACE = 'course_home_level_messages'
+
+ @classmethod
+ def get_namespace(cls):
+ """
+ Returns the namespace of the message collection.
+ """
+ return cls.NAMESPACE
diff --git a/openedx/features/course_experience/static/course_experience/fixtures/course-home-fragment.html b/openedx/features/course_experience/static/course_experience/fixtures/course-home-fragment.html
new file mode 100644
index 0000000000..4c9252e81c
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/fixtures/course-home-fragment.html
@@ -0,0 +1,109 @@
+
+ ${_("We've built a suggested schedule to help you stay on track.")}
+ ${_("But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on our suggested dates, you'll be able to adjust them to keep yourself on track.")}
+
+
+
+
+
diff --git a/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html b/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html
new file mode 100644
index 0000000000..64abaf83d6
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+ Start Course
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
-
+ + + Introduction ++
-
+
- + + Demo Course Overview + + +
+ -
+ + + Example Week 1: Getting Started ++
-
+
- + + Lesson 1 - Getting Started + + +
- + + Homework - Question Styles + + +
+ -
+ + + Example Week 2: Get Interactive ++
-
+
- + + Lesson 2 - Let's Get Interactive! + + +
- + + Homework - Labs and Demos + + +
- + + Homework - Essays + + +
+ -
+ + + Example Week 3: Be Social ++
-
+
- + + Lesson 3 - Be Social + + +
- + + Homework - Find Your Study Buddy + + +
- + + More Ways to Connect + + +
+ -
+ + + About Exams and Certificates ++
-
+
- + + edX Exams + + +
+ -
+ + + holding section ++
-
+
- + + New Subsection + + +
+
+
diff --git a/openedx/features/course_experience/static/course_experience/fixtures/welcome-message-fragment.html b/openedx/features/course_experience/static/course_experience/fixtures/welcome-message-fragment.html
new file mode 100644
index 0000000000..e83f92403d
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/fixtures/welcome-message-fragment.html
@@ -0,0 +1,32 @@
+Latest Update
+
+
+
+ This is an update.
+
+
diff --git a/openedx/features/course_experience/static/course_experience/images/home_message_author.png b/openedx/features/course_experience/static/course_experience/images/home_message_author.png
new file mode 100644
index 0000000000..75425438be
Binary files /dev/null and b/openedx/features/course_experience/static/course_experience/images/home_message_author.png differ
diff --git a/openedx/features/course_experience/static/course_experience/js/CourseGoals.js b/openedx/features/course_experience/static/course_experience/js/CourseGoals.js
new file mode 100644
index 0000000000..467997b759
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/CourseGoals.js
@@ -0,0 +1,50 @@
+/* globals gettext */
+
+import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
+
+export class CourseGoals { // eslint-disable-line import/prefer-default-export
+
+ constructor(options) {
+ $('.goal-option').click((e) => {
+ const goalKey = $(e.target).data().choice;
+ $.ajax({
+ method: 'POST',
+ url: options.goalApiUrl,
+ headers: { 'X-CSRFToken': $.cookie('csrftoken') },
+ data: {
+ goal_key: goalKey,
+ course_key: options.courseId,
+ user: options.username,
+ },
+ dataType: 'json',
+ success: (data) => { // LEARNER-2522 will address the success message
+ $('.section-goals').slideDown();
+ $('.section-goals .goal .text').text(data.goal_text);
+ $('.section-goals select').val(data.goal_key);
+ const successMsg = HtmlUtils.interpolateHtml(
+ gettext('Thank you for setting your course goal to {goal}!'),
+ { goal: data.goal_text.toLowerCase() },
+ );
+ if (!data.is_unsure) {
+ // xss-lint: disable=javascript-jquery-html
+ $('.message-content').html(`
+
+
+
+
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+ This is a useful welcome message that is too long!
+
+
+
+${successMsg}
`);
+ } else {
+ $('.message-content').parent().hide();
+ }
+ },
+ error: () => { // LEARNER-2522 will address the error message
+ const errorMsg = gettext('There was an error in setting your goal, please reload the page and try again.');
+ // xss-lint: disable=javascript-jquery-html
+ $('.message-content').html(` ${errorMsg}
`);
+ },
+ });
+ });
+
+ // Allow goal selection with an enter press for accessibility purposes
+ $('.goal-option').keypress((e) => {
+ if (e.which === 13) {
+ $(e.target).click();
+ }
+ });
+ }
+}
diff --git a/openedx/features/course_experience/static/course_experience/js/CourseHome.js b/openedx/features/course_experience/static/course_experience/js/CourseHome.js
new file mode 100644
index 0000000000..7ee8023cc4
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/CourseHome.js
@@ -0,0 +1,160 @@
+/* globals gettext, Logger */
+
+export class CourseHome { // eslint-disable-line import/prefer-default-export
+ constructor(options) {
+ this.courseRunKey = options.courseRunKey;
+ this.msgStateStorageKey = `course_experience.upgrade_msg.${this.courseRunKey}.collapsed`;
+
+ // Logging for 'Resume Course' or 'Start Course' button click
+ const $resumeCourseLink = $(options.resumeCourseLink);
+ $resumeCourseLink.on('click', (event) => {
+ const eventType = $resumeCourseLink.find('span').data('action-type');
+ Logger.log(
+ 'edx.course.home.resume_course.clicked',
+ {
+ event_type: eventType,
+ url: event.currentTarget.href,
+ },
+ );
+ });
+
+ // Logging for course tool click events
+ const $courseToolLink = $(options.courseToolLink);
+ $courseToolLink.on('click', (event) => {
+ const courseToolName = event.srcElement.dataset['analytics-id']; // eslint-disable-line dot-notation
+ Logger.log(
+ 'edx.course.tool.accessed',
+ {
+ tool_name: courseToolName,
+ },
+ );
+ });
+
+ // Course goal editing elements
+ const $goalSection = $('.section-goals');
+ const $editGoalIcon = $('.section-goals .edit-icon');
+ const $currentGoalText = $('.section-goals .goal');
+ const $goalSelect = $('.section-goals .edit-goal-select');
+ const $responseIndicator = $('.section-goals .response-icon');
+ const $responseMessageSr = $('.section-goals .sr-update-response-msg');
+ const $goalUpdateTitle = $('.section-goals .title:not("label")');
+ const $goalUpdateLabel = $('.section-goals label.title');
+
+ // Switch to editing mode when the goal section is clicked
+ $goalSection.on('click', (event) => {
+ if (!$(event.target).hasClass('edit-goal-select')) {
+ $goalSelect.toggle();
+ $currentGoalText.toggle();
+ $goalUpdateTitle.toggle();
+ $goalUpdateLabel.toggle();
+ $responseIndicator.removeClass().addClass('response-icon');
+ $goalSelect.focus();
+ }
+ });
+
+ // Trigger click event on enter press for accessibility purposes
+ $(document.body).on('keyup', '.section-goals .edit-icon', (event) => {
+ if (event.which === 13) {
+ $(event.target).trigger('click');
+ }
+ });
+
+ // Send an ajax request to update the course goal
+ $goalSelect.on('blur change', (event) => {
+ $currentGoalText.show();
+ $goalUpdateTitle.show();
+ $goalUpdateLabel.hide();
+ $goalSelect.hide();
+ // No need to update in the case of a blur event
+ if (event.type === 'blur') return;
+ const newGoalKey = $(event.target).val();
+ $responseIndicator.removeClass().addClass('response-icon fa fa-spinner fa-spin');
+ $.ajax({
+ method: 'POST',
+ url: options.goalApiUrl,
+ headers: { 'X-CSRFToken': $.cookie('csrftoken') },
+ data: {
+ goal_key: newGoalKey,
+ course_key: options.courseId,
+ user: options.username,
+ },
+ dataType: 'json',
+ success: (data) => {
+ $currentGoalText.find('.text').text(data.goal_text);
+ $responseMessageSr.text(gettext('You have successfully updated your goal.'));
+ $responseIndicator.removeClass().addClass('response-icon fa fa-check');
+ },
+ error: () => {
+ $responseIndicator.removeClass().addClass('response-icon fa fa-close');
+ $responseMessageSr.text(gettext('There was an error updating your goal.'));
+ },
+ complete: () => {
+ // Only show response icon indicator for 3 seconds.
+ setTimeout(() => {
+ $responseIndicator.removeClass().addClass('response-icon');
+ }, 3000);
+ $editGoalIcon.focus();
+ },
+ });
+ });
+
+ // Dismissibility for in course messages
+ $(document.body).on('click', '.course-message .dismiss', (event) => {
+ $(event.target).closest('.course-message').hide();
+ });
+
+ // Allow dismiss on enter press for accessibility purposes
+ $(document.body).on('keyup', '.course-message .dismiss', (event) => {
+ if (event.which === 13) {
+ $(event.target).trigger('click');
+ }
+ });
+
+ $(document).ready(() => {
+ this.configureUpgradeMessage();
+ this.configureUpgradeAnalytics();
+ });
+ }
+
+ static fireSegmentEvent(event, properties) {
+ /* istanbul ignore next */
+ if (!window.analytics) {
+ return;
+ }
+
+ window.analytics.track(event, properties);
+ }
+
+ // Promotion analytics for upgrade messages on course home.
+ // eslint-disable-next-line class-methods-use-this
+ configureUpgradeAnalytics() {
+ $('.btn-upgrade').each(
+ (index, button) => {
+ const promotionEventProperties = {
+ promotion_id: 'courseware_verified_certificate_upsell',
+ creative: $(button).data('creative'),
+ name: 'In-Course Verification Prompt',
+ position: $(button).data('position'),
+ };
+ CourseHome.fireSegmentEvent('Promotion Viewed', promotionEventProperties);
+ $(button).click(() => {
+ CourseHome.fireSegmentEvent('Promotion Clicked', promotionEventProperties);
+ });
+ },
+ );
+ }
+
+ configureUpgradeMessage() {
+ const logEventProperties = { courseRunKey: this.courseRunKey };
+
+ Logger.log('edx.bi.course.upgrade.sidebarupsell.displayed', logEventProperties);
+ $('.section-upgrade .btn-upgrade').click(() => {
+ Logger.log('edx.bi.course.upgrade.sidebarupsell.clicked', logEventProperties);
+ Logger.log('edx.course.enrollment.upgrade.clicked', { location: 'sidebar-message' });
+ });
+ $('.promo-learn-more').click(() => {
+ $('.action-toggle-verification-sock').click();
+ $('.action-toggle-verification-sock')[0].scrollIntoView({ behavior: 'smooth', alignToTop: true });
+ });
+ }
+}
diff --git a/openedx/features/course_experience/static/course_experience/js/CourseOutline.js b/openedx/features/course_experience/static/course_experience/js/CourseOutline.js
new file mode 100644
index 0000000000..587d1098b5
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/CourseOutline.js
@@ -0,0 +1,112 @@
+/* globals Logger */
+
+import { keys } from 'edx-ui-toolkit/js/utils/constants';
+
+// @TODO: Figure out how to make webpack handle default exports when libraryTarget: 'window'
+export class CourseOutline { // eslint-disable-line import/prefer-default-export
+ constructor() {
+ const focusable = [...document.querySelectorAll('.outline-item.focusable')];
+
+ focusable.forEach(el => el.addEventListener('keydown', (event) => {
+ const index = focusable.indexOf(event.target);
+
+ switch (event.key) { // eslint-disable-line default-case
+ case keys.down:
+ event.preventDefault();
+ focusable[Math.min(index + 1, focusable.length - 1)].focus();
+ break;
+ case keys.up: // @TODO: Get these from the UI Toolkit
+ event.preventDefault();
+ focusable[Math.max(index - 1, 0)].focus();
+ break;
+ }
+ }));
+
+ [...document.querySelectorAll('a:not([href^="#"])')]
+ .forEach(link => link.addEventListener('click', (event) => {
+ Logger.log(
+ 'edx.ui.lms.link_clicked',
+ {
+ current_url: window.location.href,
+ target_url: event.currentTarget.href,
+ },
+ );
+ }),
+ );
+
+ function expandSection(sectionToggleButton) {
+ const $toggleButtonChevron = $(sectionToggleButton).children('.fa-chevron-right');
+ const $contentPanel = $(document.getElementById(sectionToggleButton.getAttribute('aria-controls')));
+
+ $contentPanel.slideDown();
+ $contentPanel.removeClass('is-hidden');
+ $toggleButtonChevron.addClass('fa-rotate-90');
+ sectionToggleButton.setAttribute('aria-expanded', 'true');
+ }
+
+ function collapseSection(sectionToggleButton) {
+ const $toggleButtonChevron = $(sectionToggleButton).children('.fa-chevron-right');
+ const $contentPanel = $(document.getElementById(sectionToggleButton.getAttribute('aria-controls')));
+
+ $contentPanel.slideUp();
+ $contentPanel.addClass('is-hidden');
+ $toggleButtonChevron.removeClass('fa-rotate-90');
+ sectionToggleButton.setAttribute('aria-expanded', 'false');
+ }
+
+ [...document.querySelectorAll(('.accordion'))]
+ .forEach((accordion) => {
+ const sections = Array.prototype.slice.call(accordion.querySelectorAll('.accordion-trigger'));
+
+ sections.forEach(section => section.addEventListener('click', (event) => {
+ const sectionToggleButton = event.currentTarget;
+ if (sectionToggleButton.classList.contains('accordion-trigger')) {
+ const isExpanded = sectionToggleButton.getAttribute('aria-expanded') === 'true';
+ if (!isExpanded) {
+ expandSection(sectionToggleButton);
+ } else if (isExpanded) {
+ collapseSection(sectionToggleButton);
+ }
+ event.stopImmediatePropagation();
+ }
+ }));
+ });
+
+ const toggleAllButton = document.querySelector('#expand-collapse-outline-all-button');
+ const toggleAllSpan = document.querySelector('#expand-collapse-outline-all-span');
+ const extraPaddingClass = 'expand-collapse-outline-all-extra-padding';
+ toggleAllButton.addEventListener('click', (event) => {
+ const toggleAllExpanded = toggleAllButton.getAttribute('aria-expanded') === 'true';
+ let sectionAction;
+ /* globals gettext */
+ if (toggleAllExpanded) {
+ toggleAllButton.setAttribute('aria-expanded', 'false');
+ sectionAction = collapseSection;
+ toggleAllSpan.classList.add(extraPaddingClass);
+ toggleAllSpan.innerText = gettext('Expand All');
+ } else {
+ toggleAllButton.setAttribute('aria-expanded', 'true');
+ sectionAction = expandSection;
+ toggleAllSpan.classList.remove(extraPaddingClass);
+ toggleAllSpan.innerText = gettext('Collapse All');
+ }
+ const sections = Array.prototype.slice.call(document.querySelectorAll('.accordion-trigger'));
+ sections.forEach((sectionToggleButton) => {
+ sectionAction(sectionToggleButton);
+ });
+ event.stopImmediatePropagation();
+ });
+
+ const urlHash = window.location.hash;
+
+ if (urlHash !== '') {
+ const button = document.getElementById(urlHash.substr(1, urlHash.length));
+ if (button.classList.contains('subsection-text')) {
+ const parentLi = button.closest('.section');
+ const parentButton = parentLi.querySelector('.section-name');
+ expandSection(parentButton);
+ }
+ expandSection(button);
+ }
+ }
+}
diff --git a/openedx/features/course_experience/static/course_experience/js/Enrollment.js b/openedx/features/course_experience/static/course_experience/js/Enrollment.js
new file mode 100644
index 0000000000..e28fd5e063
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/Enrollment.js
@@ -0,0 +1,45 @@
+
+/*
+ * Course Enrollment on the Course Home page
+ */
+export class CourseEnrollment { // eslint-disable-line import/prefer-default-export
+ /**
+ * Redirect to a URL. Mainly useful for mocking out in tests.
+ * @param {string} url The URL to redirect to.
+ */
+ static redirect(url) {
+ window.location.href = url;
+ }
+
+ static refresh() {
+ window.location.reload(false);
+ }
+
+ static createEnrollment(courseId) {
+ const data = JSON.stringify({
+ course_details: { course_id: courseId },
+ });
+ const enrollmentAPI = '/api/enrollment/v1/enrollment';
+ const trackSelection = '/course_modes/choose/';
+
+ return () =>
+ $.ajax(
+ {
+ type: 'POST',
+ url: enrollmentAPI,
+ data,
+ contentType: 'application/json',
+ }).done(() => {
+ window.analytics.track('edx.bi.user.course-home.enrollment');
+ CourseEnrollment.refresh();
+ }).fail(() => {
+ // If the simple enrollment we attempted failed, go to the track selection page,
+ // which is better for handling more complex enrollment situations.
+ CourseEnrollment.redirect(trackSelection + courseId);
+ });
+ }
+
+ constructor(buttonClass, courseId) {
+ $(buttonClass).click(CourseEnrollment.createEnrollment(courseId));
+ }
+}
diff --git a/openedx/features/course_experience/static/course_experience/js/LatestUpdate.js b/openedx/features/course_experience/static/course_experience/js/LatestUpdate.js
new file mode 100644
index 0000000000..fc9fe52ef0
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/LatestUpdate.js
@@ -0,0 +1,15 @@
+/* globals $ */
+import 'jquery.cookie';
+
+export class LatestUpdate { // eslint-disable-line import/prefer-default-export
+
+ constructor(options) {
+ if ($.cookie('update-message') === 'hide') {
+ $(options.messageContainer).hide();
+ }
+ $(options.dismissButton).click(() => {
+ $.cookie('update-message', 'hide', { expires: 1 });
+ $(options.messageContainer).hide();
+ });
+ }
+}
diff --git a/openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js b/openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js
new file mode 100644
index 0000000000..90685eac52
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js
@@ -0,0 +1,65 @@
+/* globals $ */
+import 'jquery.cookie'; // eslint-disable-line
+import gettext from 'gettext'; // eslint-disable-line
+import { clampHtmlByWords } from 'common/js/utils/clamp-html'; // eslint-disable-line
+
+export class WelcomeMessage { // eslint-disable-line import/prefer-default-export
+
+ static dismissWelcomeMessage(dismissUrl) {
+ $.ajax({
+ type: 'POST',
+ url: dismissUrl,
+ headers: {
+ 'X-CSRFToken': $.cookie('csrftoken'),
+ },
+ success: () => {
+ $('.welcome-message').hide();
+ },
+ });
+ }
+
+ constructor(options) {
+ // Dismiss the welcome message if the user clicks dismiss, or auto-dismiss if
+ // the user doesn't click dismiss in 7 days from when it was first viewed.
+
+ // Check to see if the welcome message has been displayed at all.
+ if ($('.welcome-message').length > 0) {
+ // If the welcome message has been viewed.
+ if ($.cookie('welcome-message-viewed') === 'True') {
+ // If the timer cookie no longer exists, dismiss the welcome message permanently.
+ if ($.cookie('welcome-message-timer') !== 'True') {
+ WelcomeMessage.dismissWelcomeMessage(options.dismissUrl);
+ }
+ } else {
+ // Set both the viewed cookie and the timer cookie.
+ $.cookie('welcome-message-viewed', 'True', { expires: 365 });
+ $.cookie('welcome-message-timer', 'True', { expires: 7 });
+ }
+ }
+ $('.dismiss-message button').click(() => WelcomeMessage.dismissWelcomeMessage(options.dismissUrl));
+
+
+ // "Show More" support for welcome messages
+ const messageContent = document.querySelector('#welcome-message-content');
+ const fullText = messageContent.innerHTML;
+ if (clampHtmlByWords(messageContent, 100) < 0) {
+ const showMoreButton = document.querySelector('#welcome-message-show-more');
+ const shortText = messageContent.innerHTML;
+
+ showMoreButton.removeAttribute('hidden');
+
+ showMoreButton.addEventListener('click', (event) => {
+ if (showMoreButton.getAttribute('data-state') === 'less') {
+ showMoreButton.textContent = gettext('Show More');
+ messageContent.innerHTML = shortText;
+ showMoreButton.setAttribute('data-state', 'more');
+ } else {
+ showMoreButton.textContent = gettext('Show Less');
+ messageContent.innerHTML = fullText;
+ showMoreButton.setAttribute('data-state', 'less');
+ }
+ event.stopImmediatePropagation();
+ });
+ }
+ }
+}
diff --git a/openedx/features/course_experience/static/course_experience/js/spec/CourseHome_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/CourseHome_spec.js
new file mode 100644
index 0000000000..a7c918769b
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/spec/CourseHome_spec.js
@@ -0,0 +1,70 @@
+/* globals Logger, loadFixtures */
+
+import { CourseHome } from '../CourseHome';
+
+describe('Course Home factory', () => {
+ let home;
+ const runKey = 'course-v1:edX+DemoX+Demo_Course';
+ window.analytics = jasmine.createSpyObj('analytics', ['page', 'track', 'trackLink']);
+
+ beforeEach(() => {
+ loadFixtures('course_experience/fixtures/course-home-fragment.html');
+ spyOn(Logger, 'log');
+ home = new CourseHome({ // eslint-disable-line no-unused-vars
+ courseRunKey: runKey,
+ resumeCourseLink: '.action-resume-course',
+ courseToolLink: '.course-tool-link',
+ });
+ });
+
+ describe('Ensure course tool click logging', () => {
+ it('sends an event when resume or start course is clicked', () => {
+ $('.action-resume-course').click();
+ expect(Logger.log).toHaveBeenCalledWith(
+ 'edx.course.home.resume_course.clicked',
+ {
+ event_type: 'start',
+ url: `http://${window.location.host}/courses/course-v1:edX+DemoX+Demo_Course/courseware` +
+ '/19a30717eff543078a5d94ae9d6c18a5/',
+ },
+ );
+ });
+
+ it('sends an event when an course tool is clicked', () => {
+ const courseToolNames = document.querySelectorAll('.course-tool-link');
+ for (let i = 0; i < courseToolNames.length; i += 1) {
+ const courseToolName = courseToolNames[i].dataset['analytics-id']; // eslint-disable-line dot-notation
+ const event = new CustomEvent('click');
+ event.srcElement = { dataset: { 'analytics-id': courseToolName } };
+ courseToolNames[i].dispatchEvent(event);
+ expect(Logger.log).toHaveBeenCalledWith(
+ 'edx.course.tool.accessed',
+ {
+ tool_name: courseToolName,
+ },
+ );
+ }
+ });
+ });
+
+ describe('Upgrade message events', () => {
+ const segmentEventProperties = {
+ promotion_id: 'courseware_verified_certificate_upsell',
+ creative: 'sidebarupsell',
+ name: 'In-Course Verification Prompt',
+ position: 'sidebar-message',
+ };
+
+ it('should send events to Segment and edX on initial load', () => {
+ expect(window.analytics.track).toHaveBeenCalledWith('Promotion Viewed', segmentEventProperties);
+ expect(Logger.log).toHaveBeenCalledWith('edx.bi.course.upgrade.sidebarupsell.displayed', { courseRunKey: runKey });
+ });
+
+ it('should send events to Segment and edX after clicking the upgrade button ', () => {
+ $('.section-upgrade .btn-upgrade').click();
+ expect(window.analytics.track).toHaveBeenCalledWith('Promotion Viewed', segmentEventProperties);
+ expect(Logger.log).toHaveBeenCalledWith('edx.bi.course.upgrade.sidebarupsell.clicked', { courseRunKey: runKey });
+ expect(Logger.log).toHaveBeenCalledWith('edx.course.enrollment.upgrade.clicked', { location: 'sidebar-message' });
+ });
+ });
+});
diff --git a/openedx/features/course_experience/static/course_experience/js/spec/CourseOutline_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/CourseOutline_spec.js
new file mode 100644
index 0000000000..258fb3ef57
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/spec/CourseOutline_spec.js
@@ -0,0 +1,113 @@
+/* globals Logger, loadFixtures */
+
+import { keys } from 'edx-ui-toolkit/js/utils/constants';
+
+import { CourseOutline } from '../CourseOutline';
+
+describe('Course Outline factory', () => {
+ let outline; // eslint-disable-line no-unused-vars
+
+ // Our block IDs are invalid DOM selectors unless we first escape `:`, `+` and `@`
+ const escapeIds = idObj => Object.assign({}, ...Object.keys(idObj).map(key => ({
+ [key]: idObj[key]
+ .replace(/@/g, '\\@')
+ .replace(/:/, '\\:')
+ .replace(/\+/g, '\\+'),
+ })));
+
+ const outlineIds = escapeIds({
+ homeworkLabsAndDemos: 'a#block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations',
+ homeworkEssays: 'a#block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e',
+ lesson3BeSocial: 'a#block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e',
+ exampleWeek3BeSocial: 'li#block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration',
+ });
+
+ describe('keyboard listener', () => {
+ const triggerKeyListener = (current, destination, key) => {
+ current.focus();
+ spyOn(destination, 'focus');
+
+ current.dispatchEvent(new KeyboardEvent('keydown', { key }));
+ };
+
+ beforeEach(() => {
+ loadFixtures('course_experience/fixtures/course-outline-fragment.html');
+ outline = new CourseOutline();
+ });
+
+ describe('when the down arrow is pressed', () => {
+ it('moves focus from a subsection to the next subsection in the outline', () => {
+ const current = document.querySelector(outlineIds.homeworkLabsAndDemos);
+ const destination = document.querySelector(outlineIds.homeworkEssays);
+
+ triggerKeyListener(current, destination, keys.down);
+
+ expect(destination.focus).toHaveBeenCalled();
+ });
+
+ it('moves focus to the subsection list if at the top of a section', () => {
+ const current = document.querySelector(outlineIds.exampleWeek3BeSocial);
+ const destination = document.querySelector(`${outlineIds.exampleWeek3BeSocial} > ol`);
+
+ triggerKeyListener(current, destination, keys.down);
+
+ expect(destination.focus).toHaveBeenCalled();
+ });
+
+ it('moves focus to the next section if on the last subsection', () => {
+ const current = document.querySelector(outlineIds.homeworkEssays);
+ const destination = document.querySelector(outlineIds.exampleWeek3BeSocial);
+
+ triggerKeyListener(current, destination, keys.down);
+
+ expect(destination.focus).toHaveBeenCalled();
+ });
+ });
+
+ describe('when the up arrow is pressed', () => {
+ it('moves focus from a subsection to the previous subsection in the outline', () => {
+ const current = document.querySelector(outlineIds.homeworkEssays);
+ const destination = document.querySelector(outlineIds.homeworkLabsAndDemos);
+
+ triggerKeyListener(current, destination, keys.up);
+
+ expect(destination.focus).toHaveBeenCalled();
+ });
+
+ it('moves focus to the section list if at the first subsection', () => {
+ const current = document.querySelector(outlineIds.lesson3BeSocial);
+ const destination = document.querySelector(`${outlineIds.exampleWeek3BeSocial} > ol`);
+
+ triggerKeyListener(current, destination, keys.up);
+
+ expect(destination.focus).toHaveBeenCalled();
+ });
+
+ it('moves focus last subsection of the previous section if at a section boundary', () => {
+ const current = document.querySelector(outlineIds.exampleWeek3BeSocial);
+ const destination = document.querySelector(outlineIds.homeworkEssays);
+
+ triggerKeyListener(current, destination, keys.up);
+
+ expect(destination.focus).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('eventing', () => {
+ beforeEach(() => {
+ loadFixtures('course_experience/fixtures/course-outline-fragment.html');
+ outline = new CourseOutline();
+ spyOn(Logger, 'log');
+ });
+
+ it('sends an event when an outline section is clicked', () => {
+ document.querySelector(outlineIds.homeworkLabsAndDemos).dispatchEvent(new Event('click'));
+
+ expect(Logger.log).toHaveBeenCalledWith('edx.ui.lms.link_clicked', {
+ target_url: `${window.location.origin}/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations`,
+ current_url: window.location.toString(),
+ });
+ });
+ });
+});
diff --git a/openedx/features/course_experience/static/course_experience/js/spec/Currency_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/Currency_spec.js
index f0233eb84a..45e59ee254 100644
--- a/openedx/features/course_experience/static/course_experience/js/spec/Currency_spec.js
+++ b/openedx/features/course_experience/static/course_experience/js/spec/Currency_spec.js
@@ -9,8 +9,6 @@ describe('Currency factory', () => {
let usaPosition;
let japanPosition;
- window.analytics = jasmine.createSpyObj('analytics', ['page', 'track', 'trackLink']);
-
beforeEach(() => {
loadFixtures('course_experience/fixtures/course-currency-fragment.html');
canadaPosition = {
@@ -50,10 +48,5 @@ describe('Currency factory', () => {
currency = new Currency();
expect($('[name="verified_mode"].discount').filter(':visible').text()).toEqual('Pursue a Verified Certificate($198 CAD $220 CAD)');
});
- it('should send event on initial load', () => {
- $.cookie('edx-price-l10n', '{"rate":1,"code":"USD","symbol":"$","countryCode":"US"}', { path: '/' });
- currency = new Currency();
- expect(window.analytics.track).toHaveBeenCalledWith('edx.bi.user.track_selection.local_currency_cookie_set');
- });
});
});
diff --git a/openedx/features/course_experience/static/course_experience/js/spec/Enrollment_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/Enrollment_spec.js
new file mode 100644
index 0000000000..6220736b89
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/spec/Enrollment_spec.js
@@ -0,0 +1,48 @@
+/* globals $, loadFixtures */
+
+import {
+ expectRequest,
+ requests as mockRequests,
+ respondWithJson,
+ respondWithError,
+} from 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers';
+import { CourseEnrollment } from '../Enrollment';
+
+
+describe('CourseEnrollment tests', () => {
+ describe('Ensure button behavior', () => {
+ const endpointUrl = '/api/enrollment/v1/enrollment';
+ const courseId = 'course-v1:edX+DemoX+Demo_Course';
+ const enrollButtonClass = '.enroll-btn';
+
+ window.analytics = jasmine.createSpyObj('analytics', ['page', 'track', 'trackLink']);
+
+ beforeEach(() => {
+ loadFixtures('course_experience/fixtures/enrollment-button.html');
+ new CourseEnrollment('.enroll-btn', courseId); // eslint-disable-line no-new
+ });
+ it('Verify that we reload on success', () => {
+ const requests = mockRequests(this);
+ $(enrollButtonClass).click();
+ expectRequest(
+ requests,
+ 'POST',
+ endpointUrl,
+ `{"course_details":{"course_id":"${courseId}"}}`,
+ );
+ spyOn(CourseEnrollment, 'refresh');
+ respondWithJson(requests);
+ expect(CourseEnrollment.refresh).toHaveBeenCalled();
+ expect(window.analytics.track).toHaveBeenCalled();
+ requests.restore();
+ });
+ it('Verify that we redirect to track selection on fail', () => {
+ const requests = mockRequests(this);
+ $(enrollButtonClass).click();
+ spyOn(CourseEnrollment, 'redirect');
+ respondWithError(requests, 403);
+ expect(CourseEnrollment.redirect).toHaveBeenCalled();
+ requests.restore();
+ });
+ });
+});
diff --git a/openedx/features/course_experience/static/course_experience/js/spec/LatestUpdate_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/LatestUpdate_spec.js
new file mode 100644
index 0000000000..23e3146541
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/spec/LatestUpdate_spec.js
@@ -0,0 +1,38 @@
+/* globals $, loadFixtures */
+
+import 'jquery.cookie';
+import { LatestUpdate } from '../LatestUpdate';
+
+
+describe('LatestUpdate tests', () => {
+ function createLatestUpdate() {
+ new LatestUpdate({ messageContainer: '.update-message', dismissButton: '.dismiss-message button' }); // eslint-disable-line no-new
+ }
+ describe('Test dismiss', () => {
+ beforeEach(() => {
+ // This causes the cookie to be deleted.
+ $.cookie('update-message', '', { expires: -1 });
+ loadFixtures('course_experience/fixtures/latest-update-fragment.html');
+ });
+
+ it('Test dismiss button', () => {
+ expect($.cookie('update-message')).toBe(null);
+ createLatestUpdate();
+ expect($('.update-message').attr('style')).toBe(undefined);
+ $('.dismiss-message button').click();
+ expect($('.update-message').attr('style')).toBe('display: none;');
+ expect($.cookie('update-message')).toBe('hide');
+ });
+
+ it('Test cookie hides update', () => {
+ $.cookie('update-message', 'hide');
+ createLatestUpdate();
+ expect($('.update-message').attr('style')).toBe('display: none;');
+
+ $.cookie('update-message', '', { expires: -1 });
+ loadFixtures('course_experience/fixtures/latest-update-fragment.html');
+ createLatestUpdate();
+ expect($('.update-message').attr('style')).toBe(undefined);
+ });
+ });
+});
diff --git a/openedx/features/course_experience/static/course_experience/js/spec/WelcomeMessage_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/WelcomeMessage_spec.js
new file mode 100644
index 0000000000..daea7536a3
--- /dev/null
+++ b/openedx/features/course_experience/static/course_experience/js/spec/WelcomeMessage_spec.js
@@ -0,0 +1,107 @@
+/* globals $, loadFixtures */
+
+import {
+ expectRequest,
+ requests as mockRequests,
+ respondWithJson,
+} from 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers';
+import { WelcomeMessage } from '../WelcomeMessage';
+
+describe('Welcome Message factory', () => {
+ describe('Ensure button click', () => {
+ const endpointUrl = '/course/course_id/dismiss_message/';
+
+ beforeEach(() => {
+ loadFixtures('course_experience/fixtures/welcome-message-fragment.html');
+ new WelcomeMessage({ dismissUrl: endpointUrl }); // eslint-disable-line no-new
+ });
+
+ it('When button click is made, ajax call is made and message is hidden.', () => {
+ const $message = $('.welcome-message');
+ const requests = mockRequests(this);
+ document.querySelector('.dismiss-message button').dispatchEvent(new Event('click'));
+ expectRequest(
+ requests,
+ 'POST',
+ endpointUrl,
+ );
+ respondWithJson(requests);
+ expect($message.attr('style')).toBe('display: none;');
+ requests.restore();
+ });
+ });
+
+ describe('Ensure cookies behave as expected', () => {
+ const endpointUrl = '/course/course_id/dismiss_message/';
+
+ function deleteAllCookies() {
+ const cookies = document.cookie.split(';');
+ cookies.forEach((cookie) => {
+ const eqPos = cookie.indexOf('=');
+ const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`;
+ });
+ }
+
+ beforeEach(() => {
+ deleteAllCookies();
+ });
+
+ function createWelcomeMessage() {
+ loadFixtures('course_experience/fixtures/welcome-message-fragment.html');
+ new WelcomeMessage({ dismissUrl: endpointUrl }); // eslint-disable-line no-new
+ }
+
+ it('Cookies are created if none exist.', () => {
+ createWelcomeMessage();
+ expect($.cookie('welcome-message-viewed')).toBe('True');
+ expect($.cookie('welcome-message-timer')).toBe('True');
+ });
+
+ it('Nothing is hidden or dismissed if the timer is still active', () => {
+ const $message = $('.welcome-message');
+ $.cookie('welcome-message-viewed', 'True');
+ $.cookie('welcome-message-timer', 'True');
+ createWelcomeMessage();
+ expect($message.attr('style')).toBe(undefined);
+ });
+
+ it('Message is dismissed if the timer has expired and the message has been viewed.', () => {
+ const requests = mockRequests(this);
+ $.cookie('welcome-message-viewed', 'True');
+ createWelcomeMessage();
+
+ const $message = $('.welcome-message');
+ expectRequest(
+ requests,
+ 'POST',
+ endpointUrl,
+ );
+ respondWithJson(requests);
+ expect($message.attr('style')).toBe('display: none;');
+ requests.restore();
+ });
+ });
+
+ describe('Shortened welcome message', () => {
+ const endpointUrl = '/course/course_id/dismiss_message/';
+
+ beforeEach(() => {
+ loadFixtures('course_experience/fixtures/welcome-message-fragment.html');
+ new WelcomeMessage({ // eslint-disable-line no-new
+ dismissUrl: endpointUrl,
+ });
+ });
+
+ it('Shortened message can be toggled', () => {
+ expect($('#welcome-message-content').text()).toContain('…');
+ expect($('#welcome-message-show-more').text()).toContain('Show More');
+ $('#welcome-message-show-more').click();
+ expect($('#welcome-message-content').text()).not.toContain('…');
+ expect($('#welcome-message-show-more').text()).toContain('Show Less');
+ $('#welcome-message-show-more').click();
+ expect($('#welcome-message-content').text()).toContain('…');
+ expect($('#welcome-message-show-more').text()).toContain('Show More');
+ });
+ });
+});
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
new file mode 100644
index 0000000000..1fa8df3826
--- /dev/null
+++ b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html
@@ -0,0 +1,236 @@
+## mako
+
+<%page expression_filter="h"/>
+<%namespace name='static' file='../static_content.html'/>
+
+<%!
+import json
+
+from django.conf import settings
+from django.utils.translation import ugettext as _
+from django.template.defaultfilters import escapejs
+from django.urls import reverse
+
+from lms.djangoapps.discussion.django_comment_client.permissions import has_permission
+from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
+from openedx.core.djangolib.markup import Text, HTML
+from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG
+from openedx.features.course_experience.course_tools import HttpMethod
+%>
+
+<%block name="header_extras">
+
+%block>
+
+<%block name="content">
+
+
+
+
+%block>
+
+<%static:webpack entry="CourseHome">
+ new CourseHome({
+ courseRunKey: "${course_key | n, js_escaped_string}",
+ resumeCourseLink: ".action-resume-course",
+ courseToolLink: ".course-tool-link",
+ goalApiUrl: "${goal_api_url | n, js_escaped_string}",
+ username: "${username | n, js_escaped_string}",
+ courseId: "${course.id | n, js_escaped_string}",
+ });
+%static:webpack>
+
+<%static:webpack entry="Enrollment">
+ new CourseEnrollment('.enroll-btn', '${course_key | n, js_escaped_string}');
+%static:webpack>
+
+<%static:require_module_async module_name="js/commerce/track_ecommerce_events" class_name="TrackECommerceEvents">
+
+ var personalizedLearnerSchedulesLink = $(".personalized_learner_schedules_button");
+ var fbeLink = $("#FBE_banner");
+ var sockLink = $("#sock");
+ var upgradeDateLink = $("#course_home_dates");
+ var GreenUpgradeLink = $("#green_upgrade");
+ var GreenUpgradeLink = $("#green_upgrade");
+ var certificateUpsellLink = $("#certificate_upsell");
+
+ TrackECommerceEvents.trackUpsellClick(personalizedLearnerSchedulesLink, 'course_home_upgrade_shift_dates', {
+ pageName: "course_home",
+ linkType: "button",
+ linkCategory: "personalized_learner_schedules"
+ });
+
+ TrackECommerceEvents.trackUpsellClick(fbeLink, 'course_home_audit_access_expires', {
+ pageName: "course_home",
+ linkType: "link",
+ linkCategory: "FBE_banner"
+ });
+
+ TrackECommerceEvents.trackUpsellClick(sockLink, 'course_home_sock', {
+ pageName: "course_home",
+ linkType: "button",
+ linkCategory: "green_upgrade"
+ });
+
+ TrackECommerceEvents.trackUpsellClick(upgradeDateLink, 'course_home_dates', {
+ pageName: "course_home",
+ linkType: "link",
+ linkCategory: "(none)"
+ });
+
+ TrackECommerceEvents.trackUpsellClick(GreenUpgradeLink, 'course_home_green', {
+ pageName: "course_home",
+ linkType: "button",
+ linkCategory: "green_upgrade"
+ });
+
+ TrackECommerceEvents.trackUpsellClick(certificateUpsellLink, 'course_home_certificate', {
+ pageName: "course_home",
+ linkType: "link",
+ linkCategory: "(none)"
+ });
+
+%static:require_module_async>
diff --git a/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html b/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html
new file mode 100644
index 0000000000..020e08e0ec
--- /dev/null
+++ b/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html
@@ -0,0 +1,40 @@
+## mako
+
+<%page expression_filter="h"/>
+<%namespace name='static' file='../static_content.html'/>
+
+<%!
+from django.utils.translation import get_language_bidi
+from django.utils.translation import ugettext as _
+from openedx.core.djangolib.js_utils import js_escaped_string
+from openedx.core.djangolib.markup import HTML
+from openedx.features.course_experience import CourseHomeMessages
+%>
+
+<%
+is_rtl = get_language_bidi()
+%>
+
+% if course_home_messages:
+ % for message in course_home_messages:
+
+
+
+
+ % if show_search:
+
+
+
+
+ % endif
+
+ % if resume_course_url:
+
+ % if has_visited_course:
+ ${_("Resume Course")}
+ % else:
+ ${_("Start Course")}
+ % endif
+
+ % endif
+
+
+
+ % if course_sock_fragment:
+ ${HTML(course_sock_fragment.body_html())}
+ % endif
+
+ % if course_expiration_fragment:
+ ${HTML(course_expiration_fragment.content)}
+ % endif
+ % if course_home_message_fragment:
+ ${HTML(course_home_message_fragment.body_html())}
+ % endif
+
+ % if update_message_fragment and not DISABLE_UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id):
+
+
+
+ ${HTML(update_message_fragment.body_html())}
+
+ % endif
+
+ % if outline_fragment:
+ ${HTML(outline_fragment.body_html())}
+ % endif
+
+ % if not is_rtl:
+
+ % endif
+
+ % endif
+
+ % endfor
+% endif
+
+<%static:webpack entry="CourseGoals">
+ new CourseGoals({
+ goalApiUrl: "${goal_api_url | n, js_escaped_string}",
+ courseId: "${course_id | n, js_escaped_string}",
+ username: "${username | n, js_escaped_string}",
+ });
+%static:webpack>
diff --git a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html
new file mode 100644
index 0000000000..7d2ce5b158
--- /dev/null
+++ b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html
@@ -0,0 +1,180 @@
+## mako
+
+<%page expression_filter="h"/>
+
+<%namespace name='static' file='../static_content.html'/>
+
+<%!
+import json
+import pytz
+from datetime import date, datetime, timedelta
+
+from django.utils import timezone
+from django.utils.translation import gettext as _
+from django.utils.translation import ngettext
+
+from lms.djangoapps.courseware.access import has_access
+from openedx.core.djangolib.markup import HTML, Text
+from openedx.features.course_experience import RELATIVE_DATES_FLAG
+%>
+
+<%
+course_sections = blocks.get('children')
+self_paced = context.get('self_paced', False)
+relative_dates_flag_is_enabled = RELATIVE_DATES_FLAG.is_enabled(course_key)
+is_course_staff = bool(user and course and has_access(user, 'staff', course, course.id))
+dates_banner_displayed = False
+%>
+
+ ${HTML(message.message_html)}
+
+ % if is_rtl:
+ -
+ % for section in course_sections:
+ <%
+ section_is_auto_opened = section.get('resume_block') is True
+ scored = 'scored' if section.get('scored', False) else ''
+ %>
+
-
+
+
-
+ % for subsection in section.get('children', []):
+ <%
+ gated_subsection = subsection['id'] in gated_content
+ needs_prereqs = not gated_content[subsection['id']]['completed_prereqs'] if gated_subsection else False
+ scored = 'scored' if subsection.get('scored', False) else ''
+ graded = 'graded' if subsection.get('graded') else ''
+ num_graded_problems = subsection.get('num_graded_problems', 0)
+ %>
+
-
+
+ % if graded and scored and 'special_exam_info' not in subsection:
+
+ % endif
+
+ ${ subsection['display_name'] } + % if num_graded_problems: + ${ngettext("({number} Question)", + "({number} Questions)", + num_graded_problems).format(number=num_graded_problems)} + % endif +
+ % if subsection.get('complete'): + + ${_("Completed")} + % endif + % if needs_prereqs: ++ + ${ _("Prerequisite: ") } + <% + prerequisite_id = gated_content[subsection['id']]['prerequisite'] + prerequisite_name = xblock_display_names.get(prerequisite_id) + %> + ${ prerequisite_name } ++ % endif ++ + ## There are behavior differences between rendering of subsections which have + ## exams (timed, graded, etc) and those that do not. + ## + ## Exam subsections expose exam status message field as well as a status icon + <% + if subsection.get('due') is None or (self_paced and not in_edx_when): + # examples: Homework, Lab, etc. + data_string = subsection.get('format') + data_datetime = "" + else: + if 'special_exam_info' in subsection: + data_string = _('due {date}') + else: + data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection.get('format')) + data_datetime = subsection.get('due') + %> + % if subsection.get('format') or 'special_exam_info' in subsection: + + % if 'special_exam_info' in subsection: + ## Display the exam status icon and status message + + + ${subsection['special_exam_info'].get('short_description', '')} + + + ## completed exam statuses should not show the due date + ## since the exam has already been submitted by the user + % if not subsection['special_exam_info'].get('in_completed_state', False): + + % endif + % else: + ## non-graded section, we just show the exam format and the due date + ## this is the standard case in edx-platform + + + % if subsection.get('graded'): + ${_("This content is graded")} + % endif + % endif + + % endif ++ +
+ % endfor
+
+ % endfor
+ -
+
+ % if graded and scored and 'special_exam_info' not in subsection:
+
+ % endif
+
+
+%block>
+
+<%static:webpack entry="LatestUpdate">
+new LatestUpdate( { messageContainer: '.update-message', dismissButton: '.dismiss-message button'});
+%static:webpack>
diff --git a/openedx/features/course_experience/templates/course_experience/welcome-message-fragment.html b/openedx/features/course_experience/templates/course_experience/welcome-message-fragment.html
new file mode 100644
index 0000000000..596b5e4377
--- /dev/null
+++ b/openedx/features/course_experience/templates/course_experience/welcome-message-fragment.html
@@ -0,0 +1,41 @@
+## mako
+
+<%page expression_filter="h"/>
+<%namespace name='static' file='../static_content.html'/>
+
+<%!
+from django.utils.translation import ugettext as _
+from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
+from openedx.core.djangolib.markup import HTML
+%>
+
+<%block name="content">
+
+
+
+ ${_("Latest Update")}
+ + ${HTML(update_html)} +
+
+%block>
+
+<%static:webpack entry="WelcomeMessage">
+ new WelcomeMessage({
+ dismissUrl: "${dismiss_url | n, js_escaped_string}",
+ });
+%static:webpack>
diff --git a/openedx/features/course_experience/tests/views/test_course_dates.py b/openedx/features/course_experience/tests/views/test_course_dates.py
new file mode 100644
index 0000000000..404ad6bc63
--- /dev/null
+++ b/openedx/features/course_experience/tests/views/test_course_dates.py
@@ -0,0 +1,48 @@
+"""
+Tests for course dates fragment.
+"""
+
+
+from datetime import datetime, timedelta
+
+import six # lint-amnesty, pylint: disable=unused-import
+from django.urls import reverse
+
+from common.djangoapps.student.tests.factories import UserFactory
+from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
+
+TEST_PASSWORD = 'test'
+
+
+class TestCourseDatesFragmentView(ModuleStoreTestCase):
+ """Tests for the course dates fragment view."""
+
+ def setUp(self):
+ super().setUp()
+ with self.store.default_store(ModuleStoreEnum.Type.split):
+ self.course = CourseFactory.create(
+ org='edX',
+ number='test',
+ display_name='Test Course',
+ start=datetime.now() - timedelta(days=30),
+ end=datetime.now() + timedelta(days=30),
+ )
+ self.user = UserFactory(password=TEST_PASSWORD)
+ self.client.login(username=self.user.username, password=TEST_PASSWORD)
+
+ self.dates_fragment_url = reverse(
+ 'openedx.course_experience.mobile_dates_fragment_view',
+ kwargs={
+ 'course_id': str(self.course.id)
+ }
+ )
+
+ def test_course_dates_fragment(self):
+ response = self.client.get(self.dates_fragment_url)
+ self.assertContains(response, 'Course ends')
+
+ self.client.logout()
+ response = self.client.get(self.dates_fragment_url)
+ assert response.status_code == 404
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 6de84ebc97..ab2abc5fef 100644
--- a/openedx/features/course_experience/tests/views/test_course_home.py
+++ b/openedx/features/course_experience/tests/views/test_course_home.py
@@ -1,16 +1,932 @@
"""
-Tests for the legacy course home page.
+Tests for the course home page.
"""
-from django.test import TestCase
+
+from datetime import datetime, timedelta
+from unittest import mock
+from urllib.parse import quote_plus
+
+import ddt
+from django.conf import settings
+from django.http import QueryDict
+from django.test.utils import override_settings
+from django.urls import reverse
+from django.utils.timezone import now
+from edx_toggles.toggles.testutils import override_waffle_flag
+from pytz import UTC
+from waffle.models import Flag
+from waffle.testutils import override_flag
+
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.course_modes.tests.factories import CourseModeFactory
+from common.djangoapps.student.tests.factories import BetaTesterFactory
+from common.djangoapps.student.tests.factories import GlobalStaffFactory
+from common.djangoapps.student.tests.factories import InstructorFactory
+from common.djangoapps.student.tests.factories import OrgInstructorFactory
+from common.djangoapps.student.tests.factories import OrgStaffFactory
+from common.djangoapps.student.tests.factories import StaffFactory
+from lms.djangoapps.commerce.models import CommerceConfiguration
+from lms.djangoapps.commerce.utils import EcommerceService
+from lms.djangoapps.course_goals.api import add_course_goal_deprecated, get_course_goal
+from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
+from lms.djangoapps.courseware.tests.helpers import get_expiration_banner_text
+from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
+from openedx.core.djangoapps.django_comment_common.models import (
+ FORUM_ROLE_ADMINISTRATOR,
+ FORUM_ROLE_COMMUNITY_TA,
+ FORUM_ROLE_GROUP_MODERATOR,
+ FORUM_ROLE_MODERATOR
+)
+from openedx.core.djangoapps.schedules.models import Schedule
+from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
+from openedx.core.djangolib.markup import HTML
+from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
+from openedx.features.course_experience import (
+ COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
+ COURSE_PRE_START_ACCESS_FLAG,
+ DISABLE_UNIFIED_COURSE_TAB_FLAG,
+ ENABLE_COURSE_GOALS,
+ SHOW_UPGRADE_MSG_ON_COURSE_HOME
+)
+from openedx.features.course_experience.tests import BaseCourseUpdatesTestCase
+from openedx.features.course_experience.tests.views.helpers import add_course_mode, remove_course_mode
+from common.djangoapps.student.models import CourseEnrollment, FBEEnrollmentExclusion
+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 # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order
+
+TEST_PASSWORD = 'test'
+TEST_CHAPTER_NAME = 'Test Chapter'
+TEST_COURSE_TOOLS = 'Course Tools'
+TEST_BANNER_CLASS = '
+
+
+
+
+ ${HTML(welcome_message_html)}
+
+
+
+'
+TEST_WELCOME_MESSAGE = '
Welcome!
' +TEST_UPDATE_MESSAGE = 'Test Update!
' +TEST_COURSE_UPDATES_TOOL = '/course/updates">' +TEST_COURSE_HOME_MESSAGE = 'course-message' +TEST_COURSE_HOME_MESSAGE_ANONYMOUS = '/login' +TEST_COURSE_HOME_MESSAGE_UNENROLLED = 'Enroll now' +TEST_COURSE_HOME_MESSAGE_PRE_START = 'Course starts in' +TEST_COURSE_GOAL_OPTIONS = 'goal-options-container' +TEST_COURSE_GOAL_UPDATE_FIELD = 'section-goals' +TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN = 'section-goals hidden' +COURSE_GOAL_DISMISS_OPTION = 'unsure' +THREE_YEARS_AGO = now() - timedelta(days=(365 * 3)) + +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES -class TestCourseHomePage(TestCase): - """Tests for the legacy course home page (the legacy course outline tab)""" +def course_home_url(course): + """ + Returns the URL for the course's home page. + + Arguments: + course (CourseBlock): The course being tested. + """ + return course_home_url_from_string(str(course.id)) + + +def course_home_url_from_string(course_key_string): + """ + Returns the URL for the course's home page. + + Arguments: + course_key_string (String): The course key as string. + """ + return reverse( + 'openedx.course_experience.course_home', + kwargs={ + 'course_id': course_key_string, + } + ) + + +class CourseHomePageTestCase(BaseCourseUpdatesTestCase): + """ + Base class for testing the course home page. + """ + + @classmethod + def setUpClass(cls): + """ + Set up a course to be used for testing. + """ + # pylint: disable=super-method-not-called + with cls.setUpClassAndTestData(): + with cls.store.default_store(ModuleStoreEnum.Type.split): + cls.course = CourseFactory.create( + org='edX', + number='test', + display_name='Test Course', + start=now() - timedelta(days=30), + metadata={"invitation_only": False} + ) + cls.private_course = CourseFactory.create( + org='edX', + number='test', + display_name='Test Private Course', + start=now() - timedelta(days=30), + metadata={"invitation_only": True} + ) + with cls.store.bulk_operations(cls.course.id): + chapter = ItemFactory.create( + category='chapter', + parent_location=cls.course.location, + display_name=TEST_CHAPTER_NAME, + ) + section = ItemFactory.create(category='sequential', parent_location=chapter.location) + section2 = ItemFactory.create(category='sequential', parent_location=chapter.location) + ItemFactory.create(category='vertical', parent_location=section.location) + ItemFactory.create(category='vertical', parent_location=section2.location) + + @classmethod + def setUpTestData(cls): + """Set up and enroll our fake user in the course.""" + super().setUpTestData() + cls.staff_user = StaffFactory(course_key=cls.course.id, password=TEST_PASSWORD) + + def create_future_course(self, specific_date=None): + """ + Creates and returns a course in the future. + """ + return CourseFactory.create( + display_name='Test Future Course', + start=specific_date if specific_date else now() + timedelta(days=30), + ) + + +class TestCourseHomePage(CourseHomePageTestCase): # lint-amnesty, pylint: disable=missing-class-docstring + @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) + def test_welcome_message_when_unified(self): + # Create a welcome message + self.create_course_update(TEST_WELCOME_MESSAGE) + + url = course_home_url(self.course) + response = self.client.get(url) + self.assertContains(response, TEST_WELCOME_MESSAGE, status_code=200) + + @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) + @override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=True) + def test_welcome_message_when_not_unified(self): + # Create a welcome message + self.create_course_update(TEST_WELCOME_MESSAGE) + + url = course_home_url(self.course) + response = self.client.get(url) + self.assertNotContains(response, TEST_WELCOME_MESSAGE, status_code=200) + + @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) + def test_updates_tool_visibility(self): + """ + Verify that the updates course tool is visible only when the course + has one or more updates. + """ + url = course_home_url(self.course) + response = self.client.get(url) + self.assertNotContains(response, TEST_COURSE_UPDATES_TOOL, status_code=200) + + 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) + + @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) + def test_queries(self): + """ + Verify that the view's query count doesn't regress. + """ + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=UTC)) + # Pre-fetch the view to populate any caches + course_home_url(self.course) + + # Fetch the view and verify the query counts + # TODO: decrease query count as part of REVO-28 + with self.assertNumQueries(66, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): + with check_mongo_calls(3): + url = course_home_url(self.course) + self.client.get(url) + + @mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) + @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) + def test_start_date_handling(self): + """ + Verify that the course home page handles start dates correctly. + """ + # The course home page should 404 for a course starting in the future + future_course = self.create_future_course(datetime(2030, 1, 1, tzinfo=UTC)) + url = course_home_url(future_course) + response = self.client.get(url) + self.assertRedirects(response, '/dashboard?notlive=Jan+01%2C+2030') + + # With the Waffle flag enabled, the course should be visible + with override_flag(COURSE_PRE_START_ACCESS_FLAG.name, True): + url = course_home_url(future_course) + response = self.client.get(url) + assert response.status_code == 200 + def test_legacy_redirect(self): """ Verify that the legacy course home page redirects to the MFE correctly. """ - response = self.client.get('/courses/course-v1:edX+test+Test_Course/course/?foo=b$r') + url = course_home_url(self.course) + '?foo=b$r' + response = self.client.get(url) assert response.status_code == 302 assert response.get('Location') == 'http://learning-mfe/course/course-v1:edX+test+Test_Course/home?foo=b%24r' + + +@ddt.ddt +@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) +class TestCourseHomePageAccess(CourseHomePageTestCase): + """ + Test access to the course home page. + """ + + def setUp(self): + 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 + self.create_course_update(TEST_WELCOME_MESSAGE) + + @ddt.data( + [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.ANONYMOUS, True, False], + [False, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ANONYMOUS, True, False], + [False, COURSE_VISIBILITY_PUBLIC, CourseUserType.ANONYMOUS, True, False], + [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.ANONYMOUS, True, False], + [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ANONYMOUS, True, True], + [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.ANONYMOUS, True, True], + + [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED, True, False], + [False, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED, True, False], + [False, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED, True, False], + [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED, True, False], + [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED, True, True], + [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED, True, True], + + [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.ENROLLED, False, True], + [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.ENROLLED, False, True], + [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ENROLLED, False, True], + [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.ENROLLED, False, True], + + [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED_STAFF, True, True], + [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED_STAFF, True, True], + [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED_STAFF, True, True], + [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED_STAFF, True, True], + + [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.GLOBAL_STAFF, True, True], + [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.GLOBAL_STAFF, True, True], + [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.GLOBAL_STAFF, True, True], + [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.GLOBAL_STAFF, True, True], + ) + @ddt.unpack + def test_home_page( + self, enable_unenrolled_access, course_visibility, user_type, + expected_enroll_message, expected_course_outline, + ): + self.create_user_for_course(self.course, user_type) + + # Render the course home page + with mock.patch('xmodule.course_module.CourseBlock.course_visibility', course_visibility): + # Test access with anonymous flag and course visibility + with override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, enable_unenrolled_access): + url = course_home_url(self.course) + response = self.client.get(url) + + private_url = course_home_url(self.private_course) + private_response = self.client.get(private_url) + + is_anonymous = user_type is CourseUserType.ANONYMOUS + is_enrolled = user_type is CourseUserType.ENROLLED + is_enrolled_or_staff = is_enrolled or user_type in ( + CourseUserType.UNENROLLED_STAFF, CourseUserType.GLOBAL_STAFF + ) + + # Verify that the course tools and dates are shown for enrolled users & staff + self.assertContains(response, TEST_COURSE_TOOLS, count=(1 if is_enrolled_or_staff else 0)) + + self.assertContains(response, 'Learn About Verified Certificate', count=(1 if is_enrolled else 0)) + + # Verify that start button, course sock, and welcome message + # are only shown to enrolled users or staff. + self.assertContains(response, 'Start Course', count=(1 if is_enrolled_or_staff else 0)) + self.assertContains(response, TEST_WELCOME_MESSAGE, count=(1 if is_enrolled_or_staff else 0)) + + # Verify the outline is shown to enrolled users, unenrolled_staff and anonymous users if allowed + self.assertContains(response, TEST_CHAPTER_NAME, count=(1 if expected_course_outline else 0)) + + # Verify the message shown to the user + if not enable_unenrolled_access or course_visibility != COURSE_VISIBILITY_PUBLIC: + self.assertContains( + response, 'To see course content', count=(1 if is_anonymous else 0) + ) + self.assertContains(response, 'Enroll now"
+
+ # Verify that unenrolled users visiting a course with a Master's track
+ # that is not the only track are shown an enroll call to action message
+ add_course_mode(self.course, CourseMode.MASTERS, 'Master\'s Mode', upgrade_deadline_expired=False)
+ remove_course_mode(self.course, CourseMode.AUDIT)
+
+ self.create_user_for_course(self.course, CourseUserType.UNENROLLED)
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED)
+ self.assertContains(response, enroll_button_html)
+
+ # Verify that unenrolled users visiting a course that contains only a Master's track
+ # are not shown an enroll call to action message
+ remove_course_mode(self.course, CourseMode.VERIFIED)
+
+ response = self.client.get(url)
+
+ expected_message = ('You must be enrolled in the course to see course content. '
+ 'Please contact your degree administrator or edX Support if you have questions.')
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
+ self.assertContains(response, expected_message)
+ self.assertNotContains(response, enroll_button_html)
+
+ @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
+ @override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True)
+ def test_course_messaging(self):
+ """
+ Ensure that the following four use cases work as expected
+
+ 1) Anonymous users are shown a course message linking them to the login page
+ 2) Unenrolled users are shown a course message allowing them to enroll
+ 3) Enrolled users who show up on the course page after the course has begun
+ are not shown a course message.
+ 4) Enrolled users who show up on the course page after the course has begun will
+ see the course expiration banner if course duration limits are on for the course.
+ 5) Enrolled users who show up on the course page before the course begins
+ are shown a message explaining when the course starts as well as a call to
+ action button that allows them to add a calendar event.
+ """
+ # Verify that anonymous users are shown a login link in the course message
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE_ANONYMOUS)
+
+ # Verify that unenrolled users are shown an enroll call to action message
+ user = self.create_user_for_course(self.course, CourseUserType.UNENROLLED)
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED)
+
+ # Verify that enrolled users are not shown any state warning message when enrolled and course has begun.
+ CourseEnrollment.enroll(user, self.course.id)
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+ self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_ANONYMOUS)
+ self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED)
+ self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START)
+
+ # Verify that enrolled users are shown the course expiration banner if content gating is enabled
+
+ # We use .save() explicitly here (rather than .objects.create) in order to force the
+ # cache to refresh.
+ config = CourseDurationLimitConfig(
+ course=CourseOverview.get_from_id(self.course.id),
+ enabled=True,
+ enabled_as_of=datetime(2018, 1, 1, tzinfo=UTC)
+ )
+ config.save()
+
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+ bannerText = get_expiration_banner_text(user, self.course)
+ self.assertContains(response, bannerText, html=True)
+
+ # Verify that enrolled users are not shown the course expiration banner if content gating is disabled
+ config.enabled = False
+ config.save()
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+ bannerText = get_expiration_banner_text(user, self.course)
+ self.assertNotContains(response, bannerText, html=True)
+
+ # Verify that enrolled users are shown 'days until start' message before start date
+ future_course = self.create_future_course()
+ CourseEnrollment.enroll(user, future_course.id)
+ url = course_home_url(future_course)
+ response = self.client.get(url)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START)
+
+ @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
+ def test_course_messaging_for_staff(self):
+ """
+ Staff users will not see the expiration banner when course duration limits
+ are on for the course.
+ """
+ config = CourseDurationLimitConfig(
+ course=CourseOverview.get_from_id(self.course.id),
+ enabled=True,
+ enabled_as_of=datetime(2018, 1, 1, tzinfo=UTC)
+ )
+ config.save()
+ url = course_home_url(self.course)
+ CourseEnrollment.enroll(self.staff_user, self.course.id)
+ response = self.client.get(url)
+ bannerText = get_expiration_banner_text(self.staff_user, self.course)
+ self.assertNotContains(response, bannerText, html=True)
+
+ @override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True)
+ @override_waffle_flag(ENABLE_COURSE_GOALS, active=True)
+ def test_course_goals(self):
+ """
+ Ensure that the following five use cases work as expected.
+
+ 1) Unenrolled users are not shown the set course goal message.
+ 2) Enrolled users are shown the set course goal message if they have not yet set a course goal.
+ 3) Enrolled users are not shown the set course goal message if they have set a course goal.
+ 4) Enrolled and verified users are not shown the set course goal message.
+ 5) Enrolled users are not shown the set course goal message in a course that cannot be verified.
+ """
+ # Create a course with a verified track.
+ verifiable_course = CourseFactory.create()
+ add_course_mode(verifiable_course, upgrade_deadline_expired=False)
+
+ # Verify that unenrolled users are not shown the set course goal message.
+ user = self.create_user_for_course(verifiable_course, CourseUserType.UNENROLLED)
+ response = self.client.get(course_home_url(verifiable_course))
+ self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
+
+ # Verify that enrolled users are shown the set course goal message in a verified course.
+ CourseEnrollment.enroll(user, verifiable_course.id)
+ response = self.client.get(course_home_url(verifiable_course))
+ self.assertContains(response, TEST_COURSE_GOAL_OPTIONS)
+
+ # Verify that enrolled users that have set a course goal are not shown the set course goal message.
+ add_course_goal_deprecated(user, verifiable_course.id, COURSE_GOAL_DISMISS_OPTION)
+ response = self.client.get(course_home_url(verifiable_course))
+ self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
+
+ # Verify that enrolled and verified users are not shown the set course goal message.
+ get_course_goal(user, verifiable_course.id).delete()
+ CourseEnrollment.enroll(user, verifiable_course.id, CourseMode.VERIFIED)
+ response = self.client.get(course_home_url(verifiable_course))
+ self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
+
+ # Verify that enrolled users are not shown the set course goal message in an audit only course.
+ audit_only_course = CourseFactory.create()
+ CourseEnrollment.enroll(user, audit_only_course.id)
+ response = self.client.get(course_home_url(audit_only_course))
+ self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
+
+ @override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True)
+ @override_waffle_flag(ENABLE_COURSE_GOALS, active=True)
+ def test_course_goal_updates(self):
+ """
+ Ensure that the following five use cases work as expected.
+
+ 1) Unenrolled users are not shown the update goal selection field.
+ 2) Enrolled users are not shown the update goal selection field if they have not yet set a course goal.
+ 3) Enrolled users are shown the update goal selection field if they have set a course goal.
+ 4) Enrolled users in the verified track are shown the update goal selection field.
+ """
+ # Create a course with a verified track.
+ verifiable_course = CourseFactory.create()
+ add_course_mode(verifiable_course, upgrade_deadline_expired=False)
+
+ # Verify that unenrolled users are not shown the update goal selection field.
+ user = self.create_user_for_course(verifiable_course, CourseUserType.UNENROLLED)
+ response = self.client.get(course_home_url(verifiable_course))
+ self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD)
+
+ # Verify that enrolled users that have not set a course goal are shown a hidden update goal selection field.
+ enrollment = CourseEnrollment.enroll(user, verifiable_course.id)
+ response = self.client.get(course_home_url(verifiable_course))
+ self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
+
+ # Verify that enrolled users that have set a course goal are shown a visible update goal selection field.
+ add_course_goal_deprecated(user, verifiable_course.id, COURSE_GOAL_DISMISS_OPTION)
+ response = self.client.get(course_home_url(verifiable_course))
+ self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD)
+ self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
+
+ # Verify that enrolled and verified users are shown the update goal selection
+ CourseEnrollment.update_enrollment(enrollment, is_active=True, mode=CourseMode.VERIFIED)
+ response = self.client.get(course_home_url(verifiable_course))
+ self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD)
+ self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
+
+
+@ddt.ddt
+@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
+class CourseHomeFragmentViewTests(ModuleStoreTestCase):
+ """
+ Test Messages Displayed on the Course Home
+ """
+ CREATE_USER = False
+
+ def setUp(self):
+ super().setUp()
+ CommerceConfiguration.objects.create(checkout_on_ecommerce_service=True)
+
+ end = now() + timedelta(days=30)
+ self.course = CourseFactory(
+ start=now() - timedelta(days=30),
+ end=end,
+ self_paced=True,
+ )
+ self.url = course_home_url(self.course)
+
+ CourseMode.objects.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) # lint-amnesty, pylint: disable=no-member
+ self.verified_mode = CourseMode.objects.create(
+ course_id=self.course.id, # lint-amnesty, pylint: disable=no-member
+ mode_slug=CourseMode.VERIFIED,
+ min_price=100,
+ expiration_datetime=end,
+ sku='test'
+ )
+
+ self.user = UserFactory()
+ self.client.login(username=self.user.username, password=TEST_PASSWORD)
+
+ self.flag, __ = Flag.objects.update_or_create(
+ name=SHOW_UPGRADE_MSG_ON_COURSE_HOME.name, defaults={'everyone': True}
+ )
+
+ def assert_upgrade_message_not_displayed(self):
+ response = self.client.get(self.url)
+ self.assertNotContains(response, 'section-upgrade')
+
+ def assert_upgrade_message_displayed(self): # lint-amnesty, pylint: disable=missing-function-docstring
+ response = self.client.get(self.url)
+ self.assertContains(response, 'section-upgrade')
+ url = EcommerceService().get_checkout_page_url(self.verified_mode.sku)
+ self.assertContains(response, '${self.verified_mode.min_price})",
+ )
+
+ def test_no_upgrade_message_if_logged_out(self):
+ self.client.logout()
+ self.assert_upgrade_message_not_displayed()
+
+ def test_no_upgrade_message_if_not_enrolled(self):
+ assert len(CourseEnrollment.enrollments_for_user(self.user)) == 0
+ self.assert_upgrade_message_not_displayed()
+
+ def test_no_upgrade_message_if_verified_track(self):
+ CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) # lint-amnesty, pylint: disable=no-member
+ self.assert_upgrade_message_not_displayed()
+
+ def test_no_upgrade_message_if_upgrade_deadline_passed(self):
+ self.verified_mode.expiration_datetime = now() - timedelta(days=20)
+ self.verified_mode.save()
+ self.assert_upgrade_message_not_displayed()
+
+ def test_no_upgrade_message_if_flag_disabled(self):
+ self.flag.everyone = False
+ self.flag.save()
+ CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) # lint-amnesty, pylint: disable=no-member
+ self.assert_upgrade_message_not_displayed()
+
+ def test_display_upgrade_message_if_audit_and_deadline_not_passed(self):
+ CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) # lint-amnesty, pylint: disable=no-member
+ self.assert_upgrade_message_displayed()
+
+ @mock.patch(
+ 'openedx.features.course_experience.views.course_home.format_strikeout_price',
+ mock.Mock(return_value=(HTML("DISCOUNT_PRICE"), True))
+ )
+ def test_upgrade_message_discount(self):
+ # pylint: disable=no-member
+ CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT)
+
+ with override_waffle_flag(SHOW_UPGRADE_MSG_ON_COURSE_HOME, True):
+ response = self.client.get(self.url)
+
+ self.assertContains(response, "DISCOUNT_PRICE")
diff --git a/openedx/features/course_experience/tests/views/test_course_outline.py b/openedx/features/course_experience/tests/views/test_course_outline.py
new file mode 100644
index 0000000000..f952ca0b7b
--- /dev/null
+++ b/openedx/features/course_experience/tests/views/test_course_outline.py
@@ -0,0 +1,767 @@
+"""
+Tests for the Course Outline view and supporting views.
+"""
+
+
+import datetime
+import re
+from unittest.mock import Mock, patch
+
+import ddt
+from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
+from completion.models import BlockCompletion
+from completion.test_utils import CompletionWaffleTestMixin
+from django.contrib.sites.models import Site
+from django.test import RequestFactory, override_settings
+from django.urls import reverse
+from django.utils import timezone
+from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch
+from milestones.tests.utils import MilestonesTestCaseMixin
+from opaque_keys.edx.keys import CourseKey, UsageKey
+from pyquery import PyQuery as pq
+from pytz import UTC
+from waffle.models import Switch
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.course_modes.tests.factories import CourseModeFactory
+from common.djangoapps.student.tests.factories import StaffFactory
+from lms.djangoapps.course_api.blocks.transformers.milestones import MilestonesAndSpecialExamsTransformer
+from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND
+from lms.djangoapps.gating import api as lms_gating_api
+from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
+from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin
+from lms.urls import RESET_COURSE_DEADLINES_NAME
+from openedx.core.djangoapps.course_date_signals.models import SelfPacedRelativeDatesConfig
+from openedx.core.djangoapps.schedules.models import Schedule
+from openedx.core.lib.gating import api as gating_api
+from openedx.features.content_type_gating.models import ContentTypeGatingConfig
+from openedx.features.course_experience import RELATIVE_DATES_FLAG
+from openedx.features.course_experience.views.course_outline import (
+ DEFAULT_COMPLETION_TRACKING_START,
+ CourseOutlineFragmentView
+)
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.student.tests.factories import UserFactory
+
+from ...utils import get_course_outline_block_tree
+from .test_course_home import course_home_url
+
+TEST_PASSWORD = 'test'
+GATING_NAMESPACE_QUALIFIER = '.gating'
+
+
+@ddt.ddt
+@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
+class TestCourseOutlinePage(SharedModuleStoreTestCase, MasqueradeMixin):
+ """
+ Test the course outline view.
+ """
+
+ ENABLED_SIGNALS = ['course_published']
+
+ @classmethod
+ def setUpClass(cls): # lint-amnesty, pylint: disable=super-method-not-called
+ """
+ Set up an array of various courses to be tested.
+ """
+ SelfPacedRelativeDatesConfig.objects.create(enabled=True)
+
+ # setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase
+ # pylint: disable=super-method-not-called
+ with super().setUpClassAndTestData():
+ cls.courses = []
+ course = CourseFactory.create(self_paced=True, start=timezone.now() - datetime.timedelta(days=1))
+ with cls.store.bulk_operations(course.id):
+ chapter = ItemFactory.create(category='chapter', parent_location=course.location)
+ sequential = ItemFactory.create(category='sequential', parent_location=chapter.location, graded=True,
+ format="Homework")
+ vertical = ItemFactory.create(category='vertical', parent_location=sequential.location)
+ ItemFactory.create(category='problem', parent_location=vertical.location)
+ cls.courses.append(cls.store.publish(course.location, ModuleStoreEnum.UserID.test))
+
+ course = CourseFactory.create()
+ with cls.store.bulk_operations(course.id):
+ chapter = ItemFactory.create(category='chapter', parent_location=course.location)
+ sequential = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ sequential2 = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ ItemFactory.create(
+ category='vertical',
+ parent_location=sequential.location,
+ display_name="Vertical 1"
+ )
+ ItemFactory.create(
+ category='vertical',
+ parent_location=sequential2.location,
+ display_name="Vertical 2"
+ )
+ cls.courses.append(cls.store.publish(course.location, ModuleStoreEnum.UserID.test))
+
+ course = CourseFactory.create()
+ with cls.store.bulk_operations(course.id):
+ chapter = ItemFactory.create(category='chapter', parent_location=course.location)
+ sequential = ItemFactory.create(
+ category='sequential',
+ parent_location=chapter.location,
+ due=datetime.datetime.now(),
+ graded=True,
+ format='Homework',
+ )
+ ItemFactory.create(category='vertical', parent_location=sequential.location)
+ cls.courses.append(cls.store.publish(course.location, ModuleStoreEnum.UserID.test))
+
+ @classmethod
+ def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called
+ """Set up and enroll our fake user in the course."""
+ cls.user = UserFactory(password=TEST_PASSWORD)
+ for course in cls.courses:
+ CourseEnrollment.enroll(cls.user, course.id)
+ Schedule.objects.update(start_date=timezone.now() - datetime.timedelta(days=1))
+
+ def setUp(self):
+ """
+ Set up for the tests.
+ """
+ super().setUp()
+ self.client.login(username=self.user.username, password=TEST_PASSWORD)
+
+ @override_waffle_flag(RELATIVE_DATES_FLAG, active=True)
+ def test_outline_details(self):
+ for course in self.courses:
+
+ url = course_home_url(course)
+
+ request_factory = RequestFactory()
+ request = request_factory.get(url)
+ request.user = self.user
+
+ course_block_tree = get_course_outline_block_tree(
+ request, str(course.id), self.user
+ )
+
+ response = self.client.get(url)
+ assert course.children
+ for chapter in course_block_tree['children']:
+ self.assertContains(response, chapter['display_name'])
+ assert chapter['children']
+ for sequential in chapter['children']:
+ self.assertContains(response, sequential['display_name'])
+ if sequential['graded']:
+ print(sequential)
+ self.assertContains(response, sequential['due'].strftime('%Y-%m-%d %H:%M:%S'))
+ self.assertContains(response, sequential['format'])
+ assert sequential['children']
+
+ def test_num_graded_problems(self):
+ course = CourseFactory.create()
+ with self.store.bulk_operations(course.id):
+ chapter = ItemFactory.create(category='chapter', parent_location=course.location)
+ sequential = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ problem = ItemFactory.create(category='problem', parent_location=sequential.location)
+ sequential2 = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ problem2 = ItemFactory.create(category='problem', graded=True, has_score=True,
+ parent_location=sequential2.location)
+ sequential3 = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ problem3_1 = ItemFactory.create(category='problem', graded=True, has_score=True,
+ parent_location=sequential3.location)
+ problem3_2 = ItemFactory.create(category='problem', graded=True, has_score=True,
+ parent_location=sequential3.location)
+ course.children = [chapter]
+ chapter.children = [sequential, sequential2, sequential3]
+ sequential.children = [problem]
+ sequential2.children = [problem2]
+ sequential3.children = [problem3_1, problem3_2]
+ CourseEnrollment.enroll(self.user, course.id)
+
+ url = course_home_url(course)
+ response = self.client.get(url)
+ content = response.content.decode('utf8')
+ self.assertRegex(content, sequential.display_name + r'\s*')
+ self.assertRegex(content, sequential2.display_name + r'\s*\(1 Question\)\s*')
+ self.assertRegex(content, sequential3.display_name + r'\s*\(2 Questions\)\s*')
+
+ @override_waffle_flag(RELATIVE_DATES_FLAG, active=True)
+ @ddt.data(
+ ([CourseMode.AUDIT, CourseMode.VERIFIED], CourseMode.AUDIT, False, True),
+ ([CourseMode.AUDIT, CourseMode.VERIFIED], CourseMode.VERIFIED, False, True),
+ ([CourseMode.MASTERS], CourseMode.MASTERS, False, True),
+ ([CourseMode.PROFESSIONAL], CourseMode.PROFESSIONAL, True, True), # staff accounts should also see the banner
+ )
+ @ddt.unpack
+ def test_reset_course_deadlines_banner_shows_for_self_paced_course(
+ self,
+ course_modes,
+ enrollment_mode,
+ is_course_staff,
+ should_display
+ ):
+ ContentTypeGatingConfig.objects.create(
+ enabled=True,
+ enabled_as_of=datetime.datetime(2017, 1, 1, tzinfo=UTC),
+ )
+ course = self.courses[0]
+ for mode in course_modes:
+ CourseModeFactory.create(course_id=course.id, mode_slug=mode)
+
+ enrollment = CourseEnrollment.objects.get(course_id=course.id, user=self.user)
+ enrollment.mode = enrollment_mode
+ enrollment.save()
+ enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=30)
+ enrollment.schedule.save()
+ self.user.is_staff = is_course_staff
+ self.user.save()
+
+ url = course_home_url(course)
+ response = self.client.get(url)
+
+ if should_display:
+ self.assertContains(response, '
= 2
+ sequential = chapter.children[0]
+ sequential2 = chapter.children[1]
+ self.complete_sequential(course, sequential)
+ self.complete_sequential(course, sequential2)
+
+ # remove one of the sequentials from the chapter
+ with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course.id):
+ self.store.delete_item(sequential.location, self.user.id)
+
+ # check resume course buttons
+ response = self.visit_course_home(course, resume_count=1)
+
+ content = pq(response.content)
+ assert content('.action-resume-course').attr('href').endswith('+type@sequential+block@' + sequential2.url_name)
+
+ def test_resume_course_deleted_sequentials(self):
+ """
+ Tests resume course when the last completed sequential is deleted and
+ there are no sequentials left in the vertical.
+
+ """
+ course = self.create_test_course()
+
+ # first navigate to a sequential to make it the last accessed
+ chapter = course.children[0]
+ assert len(chapter.children) == 2
+ sequential = chapter.children[0]
+ self.complete_sequential(course, sequential)
+
+ # remove all sequentials from chapter
+ with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course.id):
+ for sequential in chapter.children:
+ self.store.delete_item(sequential.location, self.user.id)
+
+ # check resume course buttons
+ self.visit_course_home(course, start_count=1, resume_count=0)
+
+ def test_course_home_for_global_staff(self):
+ """
+ Tests that staff user can access the course home without being enrolled
+ in the course.
+ """
+ course = self.course
+ self.user.is_staff = True
+ self.user.save()
+
+ self.override_waffle_switch(True)
+ CourseEnrollment.get_enrollment(self.user, course.id).delete()
+ response = self.visit_course_home(course, start_count=1, resume_count=0)
+ content = pq(response.content)
+ problem = course.children[0].children[0].children[0].children[0]
+ assert content('.action-resume-course').attr('href').endswith('+type@problem+block@' + problem.url_name)
+
+ @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=True)
+ def test_course_outline_auto_open(self):
+ """
+ Tests that the course outline auto-opens to the first subsection
+ in a course if a user has no completion data, and to the
+ last-accessed subsection if a user does have completion data.
+ """
+ def get_sequential_button(url, is_hidden):
+ is_hidden_string = "is-hidden" if is_hidden else ""
+
+ return ""
+ # Course tree
+ course = self.course
+ chapter1 = course.children[0]
+ chapter2 = course.children[1]
+
+ response_content = self.client.get(course_home_url(course)).content
+ stripped_response = str(re.sub(b"\\s+", b"", response_content), "utf-8")
+
+ assert get_sequential_button(str(chapter1.location), False) in stripped_response
+ assert get_sequential_button(str(chapter2.location), True) in stripped_response
+
+ content = pq(response_content)
+ button = content('#expand-collapse-outline-all-button')
+ assert 'Expand All' == button.children()[0].text
+
+ def test_user_enrolled_after_completion_collection(self):
+ """
+ Tests that the _completion_data_collection_start() method returns the created
+ time of the waffle switch that enables completion data tracking.
+ """
+ view = CourseOutlineFragmentView()
+ switch_name = ENABLE_COMPLETION_TRACKING_SWITCH.name
+ switch, _ = Switch.objects.get_or_create(name=switch_name)
+
+ # pylint: disable=protected-access
+ assert switch.created == view._completion_data_collection_start()
+
+ switch.delete()
+
+ def test_user_enrolled_after_completion_collection_default(self):
+ """
+ Tests that the _completion_data_collection_start() method returns a default constant
+ when no Switch object exists for completion data tracking.
+ """
+ view = CourseOutlineFragmentView()
+
+ # pylint: disable=protected-access
+ assert DEFAULT_COMPLETION_TRACKING_START == view._completion_data_collection_start()
+
+
+@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
+class TestCourseOutlinePreview(SharedModuleStoreTestCase, MasqueradeMixin):
+ """
+ Unit tests for staff preview of the course outline.
+ """
+ def test_preview(self):
+ """
+ Verify the behavior of preview for the course outline.
+ """
+ course = CourseFactory.create(
+ start=datetime.datetime.now() - datetime.timedelta(days=30)
+ )
+ staff_user = StaffFactory(course_key=course.id, password=TEST_PASSWORD)
+ CourseEnrollment.enroll(staff_user, course.id)
+
+ future_date = datetime.datetime.now() + datetime.timedelta(days=30)
+ with self.store.bulk_operations(course.id):
+ chapter = ItemFactory.create(
+ category='chapter',
+ parent_location=course.location,
+ display_name='First Chapter',
+ )
+ sequential = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ ItemFactory.create(category='vertical', parent_location=sequential.location)
+ chapter = ItemFactory.create(
+ category='chapter',
+ parent_location=course.location,
+ display_name='Future Chapter',
+ start=future_date,
+ )
+ sequential = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ ItemFactory.create(category='vertical', parent_location=sequential.location)
+
+ # Verify that a staff user sees a chapter with a due date in the future
+ self.client.login(username=staff_user.username, password='test')
+ url = course_home_url(course)
+ response = self.client.get(url)
+ assert response.status_code == 200
+ self.assertContains(response, 'Future Chapter')
+
+ # Verify that staff masquerading as a learner see the future chapter.
+ self.update_masquerade(course=course, role='student')
+ response = self.client.get(url)
+ assert response.status_code == 200
+ self.assertContains(response, 'Future Chapter')
diff --git a/openedx/features/course_experience/tests/views/test_course_sock.py b/openedx/features/course_experience/tests/views/test_course_sock.py
index 6b361f8185..a37cc02171 100644
--- a/openedx/features/course_experience/tests/views/test_course_sock.py
+++ b/openedx/features/course_experience/tests/views/test_course_sock.py
@@ -6,12 +6,11 @@ Tests for course verification sock
from unittest import mock
import ddt
-from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.commerce.models import CommerceConfiguration
-from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND
+from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from openedx.core.djangolib.markup import HTML
from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
@@ -19,13 +18,14 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase #
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
from .helpers import add_course_mode
+from .test_course_home import course_home_url
TEST_PASSWORD = 'test'
TEST_VERIFICATION_SOCK_LOCATOR = '
DISCOUNT_PRICE"), True))
)
def test_upgrade_message_discount(self):
- response = self.get_courseware(self.verified_course)
+ response = self.client.get(course_home_url(self.verified_course))
self.assertContains(response, "DISCOUNT_PRICE")
def assert_verified_sock_is_visible(self, course, response): # lint-amnesty, pylint: disable=unused-argument
diff --git a/openedx/features/course_experience/tests/views/test_masquerade.py b/openedx/features/course_experience/tests/views/test_masquerade.py
index b366d5ef2d..6cb06eaf38 100644
--- a/openedx/features/course_experience/tests/views/test_masquerade.py
+++ b/openedx/features/course_experience/tests/views/test_masquerade.py
@@ -2,12 +2,10 @@
Tests for masquerading functionality on course_experience
"""
-from django.urls import reverse
-
from edx_toggles.toggles.testutils import override_waffle_flag
+from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin
-from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND
-from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG
+from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG, SHOW_UPGRADE_MSG_ON_COURSE_HOME
from common.djangoapps.student.roles import CourseStaffRole
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
@@ -16,9 +14,11 @@ from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID # lint-
from xmodule.partitions.partitions_service import PartitionService # lint-amnesty, pylint: disable=wrong-import-order
from .helpers import add_course_mode
+from .test_course_home import course_home_url
from .test_course_sock import TEST_VERIFICATION_SOCK_LOCATOR
TEST_PASSWORD = 'test'
+UPGRADE_MESSAGE_CONTAINER = 'section-upgrade'
class MasqueradeTestBase(SharedModuleStoreTestCase, MasqueradeMixin):
@@ -67,15 +67,17 @@ class TestVerifiedUpgradesWithMasquerade(MasqueradeTestBase):
Tests for the course verification upgrade messages while the user is being masqueraded.
"""
- @override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True)
+ @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
+ @override_waffle_flag(SHOW_UPGRADE_MSG_ON_COURSE_HOME, active=True)
def test_masquerade_as_student(self):
# Elevate the staff user to be student
self.update_masquerade(course=self.verified_course, user_partition_id=ENROLLMENT_TRACK_PARTITION_ID)
- response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.verified_course.id)}))
+ response = self.client.get(course_home_url(self.verified_course))
self.assertContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False)
+ self.assertContains(response, UPGRADE_MESSAGE_CONTAINER, html=False)
- @override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True)
+ @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
def test_masquerade_as_verified_student(self):
user_group_id = self.get_group_id_by_course_mode_name(
@@ -84,10 +86,11 @@ class TestVerifiedUpgradesWithMasquerade(MasqueradeTestBase):
)
self.update_masquerade(course=self.verified_course, group_id=user_group_id,
user_partition_id=ENROLLMENT_TRACK_PARTITION_ID)
- response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.verified_course.id)}))
+ response = self.client.get(course_home_url(self.verified_course))
self.assertNotContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False)
+ self.assertNotContains(response, UPGRADE_MESSAGE_CONTAINER, html=False)
- @override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True)
+ @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
def test_masquerade_as_masters_student(self):
user_group_id = self.get_group_id_by_course_mode_name(
@@ -96,6 +99,6 @@ class TestVerifiedUpgradesWithMasquerade(MasqueradeTestBase):
)
self.update_masquerade(course=self.masters_course, group_id=user_group_id,
user_partition_id=ENROLLMENT_TRACK_PARTITION_ID)
- response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.masters_course.id)}))
-
+ response = self.client.get(course_home_url(self.masters_course))
self.assertNotContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False)
+ self.assertNotContains(response, UPGRADE_MESSAGE_CONTAINER, html=False)
diff --git a/openedx/features/course_experience/tests/views/test_welcome_message.py b/openedx/features/course_experience/tests/views/test_welcome_message.py
new file mode 100644
index 0000000000..e94d332230
--- /dev/null
+++ b/openedx/features/course_experience/tests/views/test_welcome_message.py
@@ -0,0 +1,80 @@
+"""
+Tests for course welcome messages.
+"""
+
+import ddt
+from django.urls import reverse
+
+from openedx.features.course_experience.tests import BaseCourseUpdatesTestCase
+
+
+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': str(course.id),
+ }
+ )
+
+
+def latest_update_url(course):
+ """
+ Returns the URL for the latest update view.
+ """
+ return reverse(
+ 'openedx.course_experience.latest_update_fragment_view',
+ kwargs={
+ 'course_id': str(course.id),
+ }
+ )
+
+
+def dismiss_message_url(course):
+ """
+ Returns the URL for the dismiss message endpoint.
+ """
+ return reverse(
+ 'openedx.course_experience.dismiss_welcome_message',
+ kwargs={
+ 'course_id': str(course.id),
+ }
+ )
+
+
+@ddt.ddt
+class TestWelcomeMessageView(BaseCourseUpdatesTestCase):
+ """
+ Tests for the course welcome message fragment view.
+
+ Also tests the LatestUpdate view because the functionality is similar.
+ """
+ @ddt.data(welcome_message_url, latest_update_url)
+ def test_message_display(self, url_generator):
+ 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))
+ assert response.status_code == 200
+ self.assertContains(response, 'Second Update')
+ self.assertContains(response, 'Dismiss')
+
+ @ddt.data(welcome_message_url, latest_update_url)
+ def test_empty_message(self, url_generator):
+ response = self.client.get(url_generator(self.course))
+ assert response.status_code == 204
+
+ def test_dismiss_welcome_message(self):
+ # Latest update is dimssed in JS and has no server/backend component.
+ self.create_course_update('First Update')
+
+ response = self.client.get(welcome_message_url(self.course))
+ assert response.status_code == 200
+ self.assertContains(response, 'First Update')
+
+ self.client.post(dismiss_message_url(self.course))
+ response = self.client.get(welcome_message_url(self.course))
+ assert 'First Update' not in response
+ assert response.status_code == 204
diff --git a/openedx/features/course_experience/urls.py b/openedx/features/course_experience/urls.py
index ac560d43f9..5d81a6b819 100644
--- a/openedx/features/course_experience/urls.py
+++ b/openedx/features/course_experience/urls.py
@@ -3,10 +3,42 @@ Defines URLs for the course experience.
"""
from django.urls import path
-from .views.course_home import outline_tab
-from .views.course_updates import CourseUpdatesView
+from .views.course_dates import CourseDatesFragmentMobileView
+from .views.course_home import CourseHomeFragmentView, CourseHomeView
+from .views.course_outline import CourseOutlineFragmentView
+from .views.course_updates import CourseUpdatesFragmentView, CourseUpdatesView
+from .views.latest_update import LatestUpdateFragmentView
+from .views.welcome_message import WelcomeMessageFragmentView, dismiss_welcome_message
+
+COURSE_HOME_VIEW_NAME = 'openedx.course_experience.course_home'
+COURSE_DATES_FRAGMENT_VIEW_NAME = 'openedx.course_experience.mobile_dates_fragment_view'
urlpatterns = [
- path('', outline_tab), # a now-removed legacy view, redirects to MFE
- path('updates', CourseUpdatesView.as_view(), name='openedx.course_experience.course_updates'),
+ path('', CourseHomeView.as_view(),
+ name=COURSE_HOME_VIEW_NAME,
+ ),
+ path('updates', CourseUpdatesView.as_view(),
+ name='openedx.course_experience.course_updates',
+ ),
+ path('home_fragment', CourseHomeFragmentView.as_view(),
+ name='openedx.course_experience.course_home_fragment_view',
+ ),
+ path('outline_fragment', CourseOutlineFragmentView.as_view(),
+ name='openedx.course_experience.course_outline_fragment_view',
+ ),
+ path('updates_fragment', CourseUpdatesFragmentView.as_view(),
+ name='openedx.course_experience.course_updates_fragment_view',
+ ),
+ path('welcome_message_fragment', WelcomeMessageFragmentView.as_view(),
+ name='openedx.course_experience.welcome_message_fragment_view',
+ ),
+ path('latest_update_fragment', LatestUpdateFragmentView.as_view(),
+ name='openedx.course_experience.latest_update_fragment_view',
+ ),
+ path('dismiss_welcome_message', dismiss_welcome_message,
+ name='openedx.course_experience.dismiss_welcome_message',
+ ),
+ path('mobile_dates_fragment', CourseDatesFragmentMobileView.as_view(),
+ name=COURSE_DATES_FRAGMENT_VIEW_NAME,
+ ),
]
diff --git a/openedx/features/course_experience/utils.py b/openedx/features/course_experience/utils.py
index 451d769eea..4c370e1f29 100644
--- a/openedx/features/course_experience/utils.py
+++ b/openedx/features/course_experience/utils.py
@@ -128,6 +128,23 @@ def get_course_outline_block_tree(request, course_id, user=None, allow_start_dat
return course_outline_root_block
+def get_resume_block(block):
+ """
+ Gets the deepest block marked as 'resume_block'.
+
+ """
+ if block.get('authorization_denial_reason') or not block.get('resume_block'):
+ return None
+ if not block.get('children'):
+ return block
+
+ for child in block['children']:
+ resume_block = get_resume_block(child)
+ if resume_block:
+ return resume_block
+ return block
+
+
def get_start_block(block):
"""
Gets the deepest block to use as the starting block.
diff --git a/openedx/features/course_experience/views/course_dates.py b/openedx/features/course_experience/views/course_dates.py
index e9f1a8d574..4557217d48 100644
--- a/openedx/features/course_experience/views/course_dates.py
+++ b/openedx/features/course_experience/views/course_dates.py
@@ -3,7 +3,11 @@ Fragment for rendering the course dates sidebar.
"""
+from django.db import transaction
+from django.http import Http404
from django.template.loader import render_to_string
+from django.utils.decorators import method_decorator
+from django.utils.translation import get_language_bidi
from opaque_keys.edx.keys import CourseKey
from web_fragments.fragment import Fragment
@@ -40,3 +44,35 @@ class CourseDatesFragmentView(EdxFragmentView):
self.add_fragment_resource_urls(dates_fragment)
return dates_fragment
+
+
+@method_decorator(transaction.non_atomic_requests, name='dispatch')
+class CourseDatesFragmentMobileView(CourseDatesFragmentView):
+ """
+ A course dates fragment to show dates on mobile apps.
+
+ Mobile apps uses WebKit mobile client to create and maintain a session with
+ the server for authenticated requests, and it hasn't exposed any way to find
+ out either session was created with the server or not so mobile app uses a
+ mechanism to automatically create/recreate session with the server for all
+ authenticated requests if the server returns 404.
+ """
+ template_name = 'course_experience/mobile/course-dates-fragment.html'
+
+ def get(self, request, *args, **kwargs):
+ if not request.user.is_authenticated:
+ raise Http404
+ return super().get(request, *args, **kwargs)
+
+ def css_dependencies(self):
+ """
+ Returns list of CSS files that this view depends on.
+
+ The helper function that it uses to obtain the list of CSS files
+ works in conjunction with the Django pipeline to ensure that in development mode
+ the files are loaded individually, but in production just the single bundle is loaded.
+ """
+ if get_language_bidi():
+ return self.get_css_dependencies('style-mobile-rtl')
+ else:
+ return self.get_css_dependencies('style-mobile')
diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py
index 7a3233fdb4..e488936d7b 100644
--- a/openedx/features/course_experience/views/course_home.py
+++ b/openedx/features/course_experience/views/course_home.py
@@ -2,13 +2,243 @@
Views for the course home page.
"""
-from django.shortcuts import redirect
-from common.djangoapps.util.views import ensure_valid_course_key
+from django.conf import settings
+from django.template.context_processors import csrf
+from django.template.loader import render_to_string
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_control
+from django.views.decorators.csrf import ensure_csrf_cookie
+from opaque_keys.edx.keys import CourseKey
+from web_fragments.fragment import Fragment
+
+from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active
+from lms.djangoapps.courseware.access import has_access
+from lms.djangoapps.courseware.courses import can_self_enroll_in_course, get_course_info_section, get_course_with_access
+from lms.djangoapps.course_goals.api import (
+ get_course_goal,
+ get_course_goal_options,
+ get_goal_api_url,
+ has_course_goal_permission
+)
+from lms.djangoapps.courseware.exceptions import CourseAccessRedirect, Redirect
+from lms.djangoapps.courseware.utils import can_show_verified_upgrade, verified_upgrade_deadline_link
+from lms.djangoapps.courseware.views.views import CourseTabView
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
+from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
+from openedx.core.djangoapps.util.maintenance_banner import add_maintenance_banner
+from openedx.features.course_duration_limits.access import generate_course_expired_fragment
+from openedx.features.course_experience import (
+ COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
+ LATEST_UPDATE_FLAG,
+ SHOW_UPGRADE_MSG_ON_COURSE_HOME,
+)
+from openedx.features.course_experience.course_tools import CourseToolsPluginManager
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
+from openedx.features.course_experience.utils import get_course_outline_block_tree, get_resume_block, get_start_block
+from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView
+from openedx.features.course_experience.views.course_home_messages import CourseHomeMessageFragmentView
+from openedx.features.course_experience.views.course_outline import CourseOutlineFragmentView
+from openedx.features.course_experience.views.course_sock import CourseSockFragmentView
+from openedx.features.course_experience.views.latest_update import LatestUpdateFragmentView
+from openedx.features.course_experience.views.welcome_message import WelcomeMessageFragmentView
+from openedx.features.discounts.utils import format_strikeout_price
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.util.views import ensure_valid_course_key
+from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE # lint-amnesty, pylint: disable=wrong-import-order
+
+EMPTY_HANDOUTS_HTML = '
')
+ )
+
+ # Add the dismissible option for users that are unsure of their goal
+ goal_choices_html += Text(
+ '{initial_tag}{choice}{closing_tag}'
+ ).format(
+ initial_tag=HTML(
+ '
')
+ ),
+ title=Text(_('Welcome to {course_display_name}')).format(
+ course_display_name=course.display_name
+ )
+ )
diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py
new file mode 100644
index 0000000000..bb9444d7be
--- /dev/null
+++ b/openedx/features/course_experience/views/course_outline.py
@@ -0,0 +1,165 @@
+"""
+Views to show a course outline.
+"""
+
+
+import datetime
+import re
+
+from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
+from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
+from django.template.context_processors import csrf
+from django.template.loader import render_to_string
+from django.urls import reverse
+import edx_when.api as edx_when_api
+from opaque_keys.edx.keys import CourseKey
+from pytz import UTC
+from waffle.models import Switch
+from web_fragments.fragment import Fragment
+from lms.djangoapps.courseware.courses import get_course_overview_with_access
+from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link
+from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
+from openedx.features.course_experience.utils import dates_banner_should_display
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.util.milestones_helpers import get_course_content_milestones
+from xmodule.course_module import COURSE_VISIBILITY_PUBLIC # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
+
+from ..utils import get_course_outline_block_tree, get_resume_block
+
+DEFAULT_COMPLETION_TRACKING_START = datetime.datetime(2018, 1, 24, tzinfo=UTC)
+
+
+class CourseOutlineFragmentView(EdxFragmentView):
+ """
+ Course outline fragment to be shown in the unified course view.
+ """
+
+ def render_to_fragment(self, request, course_id, user_is_enrolled=True, **kwargs): # pylint: disable=arguments-differ
+ """
+ Renders the course outline as a fragment.
+ """
+ from lms.urls import RESET_COURSE_DEADLINES_NAME
+
+ course_key = CourseKey.from_string(course_id)
+ course_overview = get_course_overview_with_access(
+ request.user, 'load', course_key, check_if_enrolled=user_is_enrolled
+ )
+ course = modulestore().get_course(course_key)
+
+ course_block_tree = get_course_outline_block_tree(
+ request, course_id, request.user if user_is_enrolled else None
+ )
+ if not course_block_tree:
+ return None
+
+ resume_block = get_resume_block(course_block_tree) if user_is_enrolled else None
+
+ if not resume_block:
+ self.mark_first_unit_to_resume(course_block_tree)
+
+ xblock_display_names = self.create_xblock_id_and_name_dict(course_block_tree)
+ gated_content = self.get_content_milestones(request, course_key)
+
+ missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user)
+
+ reset_deadlines_url = reverse(RESET_COURSE_DEADLINES_NAME)
+
+ context = {
+ 'csrf': csrf(request)['csrf_token'],
+ 'course': course_overview,
+ 'due_date_display_format': course.due_date_display_format,
+ 'blocks': course_block_tree,
+ 'enable_links': user_is_enrolled or course.course_visibility == COURSE_VISIBILITY_PUBLIC,
+ 'course_key': course_key,
+ 'gated_content': gated_content,
+ 'xblock_display_names': xblock_display_names,
+ 'self_paced': course.self_paced,
+
+ # We're using this flag to prevent old self-paced dates from leaking out on courses not
+ # managed by edx-when.
+ 'in_edx_when': edx_when_api.is_enabled_for_course(course_key),
+ 'reset_deadlines_url': reset_deadlines_url,
+ 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course),
+ 'on_course_outline_page': True,
+ 'missed_deadlines': missed_deadlines,
+ 'missed_gated_content': missed_gated_content,
+ 'has_ended': course.has_ended(),
+ }
+
+ html = render_to_string('course_experience/course-outline-fragment.html', context)
+ return Fragment(html)
+
+ def create_xblock_id_and_name_dict(self, course_block_tree, xblock_display_names=None):
+ """
+ Creates a dictionary mapping xblock IDs to their names, using a course block tree.
+ """
+ if xblock_display_names is None:
+ xblock_display_names = {}
+
+ if not course_block_tree.get('authorization_denial_reason'):
+ if course_block_tree.get('id'):
+ xblock_display_names[course_block_tree['id']] = course_block_tree['display_name']
+
+ if course_block_tree.get('children'):
+ for child in course_block_tree['children']:
+ self.create_xblock_id_and_name_dict(child, xblock_display_names)
+
+ return xblock_display_names
+
+ def get_content_milestones(self, request, course_key):
+ """
+ Returns dict of subsections with prerequisites and whether the prerequisite has been completed or not
+ """
+ def _get_key_of_prerequisite(namespace):
+ return re.sub('.gating', '', namespace)
+
+ all_course_milestones = get_course_content_milestones(course_key)
+
+ uncompleted_prereqs = {
+ milestone['content_id']
+ for milestone in get_course_content_milestones(course_key, user_id=request.user.id)
+ }
+
+ gated_content = {
+ milestone['content_id']: {
+ 'completed_prereqs': milestone['content_id'] not in uncompleted_prereqs,
+ 'prerequisite': _get_key_of_prerequisite(milestone['namespace'])
+ }
+ for milestone in all_course_milestones
+ }
+
+ return gated_content
+
+ def user_enrolled_after_completion_collection(self, user, course_key):
+ """
+ Checks that the user has enrolled in the course after 01/24/2018, the date that
+ the completion API began data collection. If the user has enrolled in the course
+ before this date, they may see incomplete collection data. This is a temporary
+ check until all active enrollments are created after the date.
+ """
+ user = User.objects.get(username=user)
+ try:
+ user_enrollment = CourseEnrollment.objects.get(
+ user=user,
+ course_id=course_key,
+ is_active=True
+ )
+ return user_enrollment.created > self._completion_data_collection_start()
+ except CourseEnrollment.DoesNotExist:
+ return False
+
+ def _completion_data_collection_start(self):
+ """
+ Returns the date that the ENABLE_COMPLETION_TRACKING waffle switch was enabled.
+ """
+ try:
+ return Switch.objects.get(name=ENABLE_COMPLETION_TRACKING_SWITCH.name).created
+ except Switch.DoesNotExist:
+ return DEFAULT_COMPLETION_TRACKING_START
+
+ def mark_first_unit_to_resume(self, block_node):
+ children = block_node.get('children')
+ if children:
+ children[0]['resume_block'] = True
+ self.mark_first_unit_to_resume(children[0])
diff --git a/openedx/features/course_experience/views/course_updates.py b/openedx/features/course_experience/views/course_updates.py
index 2e16cbc250..f0acff801b 100644
--- a/openedx/features/course_experience/views/course_updates.py
+++ b/openedx/features/course_experience/views/course_updates.py
@@ -2,9 +2,11 @@
Views that handle course updates.
"""
+import six # lint-amnesty, pylint: disable=unused-import
from django.contrib.auth.decorators import login_required
from django.template.context_processors import csrf
from django.template.loader import render_to_string
+from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from opaque_keys.edx.keys import CourseKey
@@ -13,7 +15,7 @@ from web_fragments.fragment import Fragment
from lms.djangoapps.courseware.courses import get_course_info_section_module, get_course_with_access
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
+from openedx.features.course_experience import default_course_url_name
from openedx.features.course_experience.course_updates import get_ordered_updates
@@ -46,7 +48,8 @@ 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 = default_course_url(course.id)
+ course_url_name = default_course_url_name(course.id)
+ course_url = reverse(course_url_name, kwargs={'course_id': str(course.id)})
ordered_updates = get_ordered_updates(request, course)
plain_html_updates = ''
diff --git a/openedx/features/course_experience/views/latest_update.py b/openedx/features/course_experience/views/latest_update.py
new file mode 100644
index 0000000000..1da34faff6
--- /dev/null
+++ b/openedx/features/course_experience/views/latest_update.py
@@ -0,0 +1,50 @@
+"""
+View logic for handling latest course updates.
+
+Although the welcome message fragment also displays the latest update,
+this fragment dismisses the message for a limited time so new updates
+will continue to appear, where the welcome message gets permanently
+dismissed.
+"""
+
+
+from django.template.loader import render_to_string
+from opaque_keys.edx.keys import CourseKey
+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.course_updates import get_current_update_for_user
+
+
+class LatestUpdateFragmentView(EdxFragmentView):
+ """
+ A fragment that displays the latest course update.
+ """
+
+ def render_to_fragment(self, request, course_id=None, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
+ """
+ Renders the latest update message fragment for the specified course.
+
+ Returns: A fragment, or None if there is no latest update message.
+ """
+ course_key = CourseKey.from_string(course_id)
+ course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
+
+ update_html = self.latest_update_html(request, course)
+ if not update_html:
+ return None
+
+ context = {
+ 'update_html': update_html,
+ }
+ html = render_to_string('course_experience/latest-update-fragment.html', context)
+ return Fragment(html)
+
+ @classmethod
+ def latest_update_html(cls, request, course):
+ """
+ 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
+ return get_current_update_for_user(request, course)
diff --git a/openedx/features/course_experience/views/welcome_message.py b/openedx/features/course_experience/views/welcome_message.py
new file mode 100644
index 0000000000..410d3425a3
--- /dev/null
+++ b/openedx/features/course_experience/views/welcome_message.py
@@ -0,0 +1,67 @@
+""" # lint-amnesty, pylint: disable=cyclic-import
+View logic for handling course welcome messages.
+"""
+
+
+import six # lint-amnesty, pylint: disable=unused-import
+from django.http import HttpResponse
+from django.template.loader import render_to_string
+from django.urls import reverse
+from django.views.decorators.csrf import ensure_csrf_cookie
+from opaque_keys.edx.keys import CourseKey
+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.course_updates import (
+ dismiss_current_update_for_user, get_current_update_for_user,
+)
+
+
+class WelcomeMessageFragmentView(EdxFragmentView):
+ """
+ A fragment that displays a course's welcome message.
+ """
+
+ def render_to_fragment(self, request, course_id=None, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
+ """
+ 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
+
+ dismiss_url = reverse(
+ 'openedx.course_experience.dismiss_welcome_message', kwargs={'course_id': str(course_key)}
+ )
+
+ context = {
+ 'dismiss_url': dismiss_url,
+ '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.
+ """
+ # Return the course update with the most recent publish date
+ return get_current_update_for_user(request, course)
+
+
+@ensure_csrf_cookie
+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)
+ course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
+ dismiss_current_update_for_user(request, course)
+ return HttpResponse()
diff --git a/openedx/features/course_search/__init__.py b/openedx/features/course_search/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/features/course_search/templates/course_search/course-search-fragment.html b/openedx/features/course_search/templates/course_search/course-search-fragment.html
new file mode 100644
index 0000000000..87f01b7493
--- /dev/null
+++ b/openedx/features/course_search/templates/course_search/course-search-fragment.html
@@ -0,0 +1,73 @@
+## mako
+
+<%page expression_filter="h"/>
+<%namespace name='static' file='../static_content.html'/>
+
+<%!
+import json
+import waffle
+
+from django.conf import settings
+from django.utils.translation import ugettext as _
+from django.template.defaultfilters import escapejs
+from django.urls import reverse
+
+from lms.djangoapps.discussion.django_comment_client.permissions import has_permission
+from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
+from openedx.core.djangolib.markup import HTML
+from openedx.features.course_experience import course_home_page_title
+%>
+
+<%block name="content">
+'
+ ).format(
+ goal_key=GOAL_KEY_CHOICES.unsure,
+ aria_label_choice=Text(_("Set goal to: {choice}")).format(
+ choice=course_goal_options[GOAL_KEY_CHOICES.unsure],
+ ),
+ ),
+ choice=Text(_('{choice}')).format(
+ choice=course_goal_options[GOAL_KEY_CHOICES.unsure],
+ ),
+ closing_tag=HTML('
'),
+ )
+
+ # Add the option to set a goal to earn a certificate,
+ # complete the course or explore the course
+ course_goals_by_commitment_level = valid_course_goals_ordered()
+ for goal in course_goals_by_commitment_level:
+ goal_key, goal_text = goal
+ goal_choices_html += HTML(
+ '{initial_tag}{goal_text}{closing_tag}'
+ ).format(
+ initial_tag=HTML(
+ '')
+ )
+
+ CourseHomeMessages.register_info_message(
+ request,
+ HTML('{goal_choices_html}{closing_tag}').format(
+ goal_choices_html=goal_choices_html,
+ closing_tag=HTML('
+
+
+
+%block>
+
+<%block name="js_extra">
+<%static:require_module module_name="course_search/js/course_search_factory" class_name="CourseSearchFactory">
+ var courseId = '${course_key | n, js_escaped_string}';
+ CourseSearchFactory({
+ courseId: courseId,
+ searchHeader: $('.page-header-search'),
+ supportsActive: false,
+ query: '${query | n, js_escaped_string}'
+ });
+%static:require_module>
+%block>
diff --git a/openedx/features/course_search/urls.py b/openedx/features/course_search/urls.py
new file mode 100644
index 0000000000..b923c240cb
--- /dev/null
+++ b/openedx/features/course_search/urls.py
@@ -0,0 +1,15 @@
+"""
+Defines URLs for course search.
+"""
+
+from django.urls import path
+from .views.course_search import CourseSearchFragmentView, CourseSearchView
+
+urlpatterns = [
+ path('', CourseSearchView.as_view(),
+ name='openedx.course_search.course_search_results',
+ ),
+ path('home_fragment', CourseSearchFragmentView.as_view(),
+ name='openedx.course_search.course_search_results_fragment_view',
+ ),
+]
diff --git a/openedx/features/course_search/views/__init__.py b/openedx/features/course_search/views/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/features/course_search/views/course_search.py b/openedx/features/course_search/views/course_search.py
new file mode 100644
index 0000000000..369a711b0f
--- /dev/null
+++ b/openedx/features/course_search/views/course_search.py
@@ -0,0 +1,68 @@
+"""
+Views for the course search page.
+"""
+
+
+from django.contrib.auth.decorators import login_required
+from django.template.context_processors import csrf
+from django.template.loader import render_to_string
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_control
+from django.views.decorators.csrf import ensure_csrf_cookie
+from opaque_keys.edx.keys import CourseKey
+from web_fragments.fragment import Fragment
+
+from lms.djangoapps.courseware.courses import get_course_overview_with_access
+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
+from common.djangoapps.util.views import ensure_valid_course_key
+
+
+class CourseSearchView(CourseTabView):
+ """
+ The home page for a course.
+ """
+ @method_decorator(login_required)
+ @method_decorator(ensure_csrf_cookie)
+ @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
+ @method_decorator(ensure_valid_course_key)
+ def get(self, request, course_id, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
+ """
+ Displays the home page for the specified course.
+ """
+ return super().get(request, course_id, 'courseware', **kwargs)
+
+ def render_to_fragment(self, request, course=None, tab=None, **kwargs): # lint-amnesty, pylint: disable=arguments-differ, unused-argument
+ course_id = str(course.id)
+ home_fragment_view = CourseSearchFragmentView()
+ return home_fragment_view.render_to_fragment(request, course_id=course_id, **kwargs)
+
+
+class CourseSearchFragmentView(EdxFragmentView):
+ """
+ A fragment to render the home page for a course.
+ """
+
+ def render_to_fragment(self, request, course_id=None, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
+ """
+ Renders the course's home page as a fragment.
+ """
+ course_key = CourseKey.from_string(course_id)
+ course = get_course_overview_with_access(request.user, 'load', course_key, check_if_enrolled=True)
+ course_url_name = default_course_url_name(course.id)
+ course_url = reverse(course_url_name, kwargs={'course_id': str(course.id)})
+
+ # Render the course home fragment
+ context = {
+ 'csrf': csrf(request)['csrf_token'],
+ 'course': course,
+ 'course_key': course_key,
+ 'course_url': course_url,
+ 'query': request.GET.get('query', ''),
+ 'disable_courseware_js': True,
+ 'uses_bootstrap': True,
+ }
+ html = render_to_string('course_search/course-search-fragment.html', context)
+ return Fragment(html)
diff --git a/openedx/features/enterprise_support/tests/test_utils.py b/openedx/features/enterprise_support/tests/test_utils.py
index ec271de137..c524b9bc21 100644
--- a/openedx/features/enterprise_support/tests/test_utils.py
+++ b/openedx/features/enterprise_support/tests/test_utils.py
@@ -14,12 +14,13 @@ from django.conf import settings
from django.contrib.sites.models import Site
from django.test import TestCase
from django.test.utils import override_settings
-from django.urls import NoReverseMatch
+from django.urls import NoReverseMatch, reverse
from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch
from opaque_keys.edx.keys import CourseKey, UsageKey
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
+from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.features.enterprise_support.tests import FEATURES_WITH_ENTERPRISE_ENABLED
from openedx.features.enterprise_support.tests.factories import (
@@ -615,6 +616,39 @@ class TestCourseAccessed(SharedModuleStoreTestCase, CompletionWaffleTestMixin):
completion=completion
)
+ def course_home_url(self, course):
+ """
+ Returns the URL for the course's home page.
+
+ Arguments:
+ course (CourseBlock): The course being tested.
+ """
+ return self.course_home_url_from_string(str(course.id))
+
+ def course_home_url_from_string(self, course_key_string):
+ """
+ Returns the URL for the course's home page.
+
+ Arguments:
+ course_key_string (String): The course key as string.
+ """
+ return reverse(
+ 'openedx.course_experience.course_home',
+ kwargs={
+ 'course_id': course_key_string,
+ }
+ )
+
+ @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
+ def test_course_accessed_for_visit_course_home(self):
+ """
+ Test that a visit to course home does not fall under course access
+ """
+ response = self.client.get(self.course_home_url(self.course))
+ assert response.status_code == 200
+ course_accessed = is_course_accessed(self.user, str(self.course.id))
+ self.assertFalse(course_accessed)
+
@override_settings(LMS_BASE='test_url:9999')
def test_course_accessed_with_completion_api(self):
"""
diff --git a/openedx/features/enterprise_support/utils.py b/openedx/features/enterprise_support/utils.py
index 018ee7967f..bf1b7bd6e7 100644
--- a/openedx/features/enterprise_support/utils.py
+++ b/openedx/features/enterprise_support/utils.py
@@ -5,11 +5,12 @@ Utility methods for Enterprise
import json
-from completion.exceptions import UnavailableCompletionData
-from completion.utilities import get_key_to_last_completed_block
from crum import get_current_request
from django.conf import settings
+from django.contrib.auth import get_backends, login
+from django.contrib.sessions.middleware import SessionMiddleware
from django.core.cache import cache
+from django.http import HttpRequest
from django.urls import NoReverseMatch, reverse
from django.utils.translation import gettext as _
from edx_django_utils.cache import TieredCache, get_cache_key
@@ -24,6 +25,7 @@ from lms.djangoapps.branding.api import get_privacy_url
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_authn.cookies import standard_cookie_settings
from openedx.core.djangolib.markup import HTML, Text
+from openedx.features.course_experience.utils import get_course_outline_block_tree, get_resume_block
ENTERPRISE_HEADER_LINKS = LegacyWaffleFlag('enterprise', 'enterprise_header_links', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation
@@ -469,6 +471,29 @@ def fetch_enterprise_customer_by_id(enterprise_uuid):
return EnterpriseCustomer.objects.get(uuid=enterprise_uuid)
+def _create_placeholder_request(user):
+ """
+ Helper method to create a placeholder request.
+
+ Arguments:
+ user (User): Django User object.
+
+ Returns:
+ request (HttpRequest): A placeholder request object.
+ """
+ request = HttpRequest()
+ middleware = SessionMiddleware()
+ middleware.process_request(request)
+ request.session.save()
+ backend = get_backends()[0]
+ user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
+ login(request, user)
+ request.user = user
+ request.META['SERVER_NAME'] = 'edx.org'
+ request.META['SERVER_PORT'] = '8080'
+ return request
+
+
def is_course_accessed(user, course_id):
"""
Check if the learner accessed the course.
@@ -480,8 +505,7 @@ def is_course_accessed(user, course_id):
Returns:
(bool): True if course has been accessed by the enterprise learner.
"""
- try:
- get_key_to_last_completed_block(user, course_id)
- return True
- except UnavailableCompletionData:
- return False
+ request = _create_placeholder_request(user)
+ course_outline_root_block = get_course_outline_block_tree(request, course_id, user)
+ resume_block = get_resume_block(course_outline_root_block) if course_outline_root_block else None
+ return bool(resume_block)
diff --git a/webpack.common.config.js b/webpack.common.config.js
index 427528cff1..0f76269a88 100644
--- a/webpack.common.config.js
+++ b/webpack.common.config.js
@@ -107,8 +107,14 @@ module.exports = Merge.smart({
CompletionOnViewService: './lms/static/completion/js/CompletionOnViewService.js',
// Features
+ CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',
+ CourseHome: './openedx/features/course_experience/static/course_experience/js/CourseHome.js',
+ CourseOutline: './openedx/features/course_experience/static/course_experience/js/CourseOutline.js',
CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js',
Currency: './openedx/features/course_experience/static/course_experience/js/currency.js',
+ Enrollment: './openedx/features/course_experience/static/course_experience/js/Enrollment.js',
+ LatestUpdate: './openedx/features/course_experience/static/course_experience/js/LatestUpdate.js',
+ WelcomeMessage: './openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js',
AnnouncementsView: './openedx/features/announcements/static/announcements/jsx/Announcements.jsx',
CookiePolicyBanner: './common/static/js/src/CookiePolicyBanner.jsx',
+
+
+
+
+
+
+
+
+
+
+
+