feat!: drop legacy course home view and related code

This was the "outline tab" view of the course. Preceded by the
course info view, succeeded by the MFE outline tab.

In addition to the course home view itself, this drops related
features:
- Legacy version of Course Goals (MFE has a newer implementation)
- Course home in-course search (MFE has no search)

The old course info view and course about views survive for now.

This also drops a few now-unused feature toggles:
- course_experience.latest_update
- course_experience.show_upgrade_msg_on_course_home
- course_experience.upgrade_deadline_message
- course_home.course_home_use_legacy_frontend

With this change, just the progress and courseware tabs are still
supported in legacy form, if you opt-in with waffle flags. The
outline and dates tabs are offered only by the MFE.

AA-798

(This is identical to previous commit be5c1a6, just reintroduced
now that the e2e tests have been fixed)
This commit is contained in:
Michael Terry
2022-03-15 09:32:14 -04:00
parent 584f400ca8
commit ce5f1bb343
86 changed files with 194 additions and 5747 deletions

View File

@@ -9,14 +9,12 @@ 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
@@ -24,7 +22,6 @@ 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.
@@ -45,10 +42,7 @@ class EmbargoMiddlewareAccessTests(UrlResetMixin, ModuleStoreTestCase):
self.course = CourseFactory.create()
self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.courseware_url = reverse(
'openedx.course_experience.course_home',
kwargs={'course_id': str(self.course.id)}
)
self.courseware_url = reverse('about_course', kwargs={'course_id': str(self.course.id)})
self.non_courseware_url = reverse('dashboard')
# Clear the cache to avoid interference between tests

View File

@@ -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_name
from openedx.features.course_experience import course_home_url
LOG = logging.getLogger(__name__)
@@ -542,9 +542,8 @@ def _get_trackable_course_home_url(course_id):
Args:
course_id (CourseKey): The course to get the home page URL for.
U
Returns:
A relative path to the course home page.
A URL to the course home page.
"""
course_url_name = course_home_url_name(course_id)
return reverse(course_url_name, args=[str(course_id)])
return course_home_url(course_id)

View File

@@ -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'/courses/{self.course.id}/course/',
'course_url': f'http://learning-mfe/course/{self.course.id}/home',
'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'/courses/{self.course.id}/course/',
'course_url': f'http://learning-mfe/course/{self.course.id}/home',
'dashboard_url': '/dashboard',
'homepage_url': '/',
'mobile_store_urls': {},

View File

@@ -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_name
from openedx.features.course_experience import course_home_url
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_home_url_name(course_key), kwargs={'course_id': course_id})
redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id}) # Studio
except NoReverseMatch:
redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
redirect_url = course_home_url(course_key) # LMS
else:
# Redirect to the learner dashboard (in LMS) or homepage (in Studio)
try:

View File

@@ -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 course info/outline page
# Check that the redirect was to the correct outline page for either lms or studio
if settings.ROOT_URLCONF == 'lms.urls':
url_pattern = '/course/'
expected_redirect_url = f'http://learning-mfe/course/{course_id}/home'
else:
url_pattern = f'/course/{str(course_key)}'
expected_redirect_url = f'/course/{course_id}'
assert response.url.endswith(url_pattern)
assert response.url == expected_redirect_url
def test_redirect_to_main(self):
# Create user and redirect to 'home' (cms) or 'dashboard' (lms)

View File

@@ -8,7 +8,6 @@ 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
@@ -22,6 +21,7 @@ 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(reverse('openedx.course_experience.course_home', args=[course_id]))
return redirect(course_home_url(course_key))

View File

@@ -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_name
from openedx.features.course_experience import default_course_url
from common.djangoapps.util.views import ensure_valid_course_key
@@ -41,8 +41,7 @@ class CourseBookmarksView(View):
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
course_url_name = default_course_url_name(course.id)
course_url = reverse(course_url_name, kwargs={'course_id': str(course.id)})
course_url = default_course_url(course.id)
# Render the bookmarks list as a fragment
bookmarks_fragment = CourseBookmarksFragmentView().render_to_fragment(request, course_id=course_id)

View File

@@ -10,6 +10,9 @@ 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
@@ -21,8 +24,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
@@ -37,14 +40,11 @@ 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(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(COURSEWARE_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,6 +52,21 @@ 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))
@@ -63,6 +78,18 @@ 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)
@@ -236,8 +263,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(**masquerade_config)
course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
response = self.client.get(course_home_url, follow=True)
response = self.get_courseware()
assert response.status_code == 200
self.assertCountEqual(response.redirect_chain, [])
banner_text = 'You lose all access to this course, including your progress,'
@@ -273,8 +299,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(username='audit')
course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
response = self.client.get(course_home_url, follow=True)
response = self.get_courseware()
assert response.status_code == 200
self.assertCountEqual(response.redirect_chain, [])
banner_text = 'You lose all access to this course, including your progress,'
@@ -309,8 +334,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(username='audit')
course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
response = self.client.get(course_home_url, follow=True)
response = self.get_courseware()
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'
@@ -360,8 +384,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(username=expired_staff.username)
course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
response = self.client.get(course_home_url, follow=True)
response = self.get_courseware()
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'
@@ -409,8 +432,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
self.update_masquerade(username=expired_staff.username)
course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
response = self.client.get(course_home_url, follow=True)
response = self.get_courseware()
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'

View File

@@ -1,28 +1,24 @@
"""
Unified course experience settings and helper methods.
"""
import crum
from django.utils.translation import gettext as _
from edx_django_utils.monitoring import set_custom_attribute
from waffle import flag_is_active # lint-amnesty, pylint: disable=invalid-django-waffle-import
from django.urls import reverse
from django.utils.translation import gettext as _
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
COURSE_EXPERIENCE_WAFFLE_FLAG_NAMESPACE, 'disable_course_outline_page', __name__
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
COURSE_EXPERIENCE_WAFFLE_FLAG_NAMESPACE, 'disable_unified_course_tab', __name__
WAFFLE_FLAG_NAMESPACE, 'disable_unified_course_tab', __name__
)
# Waffle flag to enable the sock on the footer of the home and courseware pages.
@@ -41,24 +37,6 @@ 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
@@ -94,48 +72,38 @@ 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): # pylint: disable=unused-argument
def course_home_page_title(_course):
"""
Returns the title for the course home page.
"""
return _('Course')
def default_course_url_name(course_id):
def default_course_url(course_key):
"""
Returns the default course URL name for the current user.
Returns the default course URL for the current user.
Arguments:
course_id (CourseKey): The course id of the current course.
course_key (CourseKey): The course id of the current course.
"""
if DISABLE_COURSE_OUTLINE_PAGE_FLAG.is_enabled(course_id):
return 'courseware'
return 'openedx.course_experience.course_home'
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')
def course_home_url_name(course_key):
def course_home_url(course_key):
"""
Returns the course home page's URL name for the current user.
Returns the course home page's URL for the current user.
Arguments:
course_key (CourseKey): The course key for which the home url is being
requested.
course_key (CourseKey): The course key for which the home url is being requested.
"""
from .url_helpers import get_learning_mfe_home_url
if DISABLE_UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key):
return 'info'
return 'openedx.course_experience.course_home'
return reverse('info', args=[str(course_key)])
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
return get_learning_mfe_home_url(course_key, url_fragment='home')

View File

@@ -1,109 +0,0 @@
<div class="course-view page-content-container" id="course-container">
<header class="page-header has-secondary">
<div class="page-header-main">
<nav aria-label="Course Outline" class="sr-is-focusable" tabindex="-1">
<h2 class="hd hd-3 page-title">Reviews Test Course</h2>
</nav>
</div>
<div class="page-header-secondary">
<div class="page-header-search">
<form class="search-form" role="search" action="/courses/course-v1:W3Cx+HTML5.0x+1T2017/search/">
<label class="field-label sr-only" for="search" id="search-hint">Search the course</label>
<input
class="field-input input-text search-input form-control"
type="search"
name="query"
id="search"
placeholder="Search the course"
/>
<button class="btn btn-small search-button" type="submit">Search</button>
</form>
</div>
<div class="form-actions">
<a class="btn btn-primary action-resume-course" href="/courses/course-v1:edX+DemoX+Demo_Course/courseware/19a30717eff543078a5d94ae9d6c18a5/">
<span data-action-type="start">Start Course</span>
</a>
</div>
</div>
</header>
<div class="page-content">
<div class="layout layout-1t2t">
<main class="layout-col layout-col-b">
<div class="section section-dates">
<div class="welcome-message">
<div class="dismiss-message">
<button type="button" class="btn-link">Dismiss</button>
</div>
This is a major update!
</div>
</div>
<main role="main" class="course-outline" id="main" tabindex="-1">
<ol class="block-tree" role="tree">
<li aria-expanded="true" class="outline-item focusable section" id="block-v1:W3Cx+HTML5.0x+1T2017+type@chapter+block@451e0388724c4f1fafba1b218ce16582" role="treeitem" tabindex="0">
<div class="section-name">
<h3 class="section-title">Testing</h3>
</div>
<ol class="outline-item focusable" role="group" tabindex="0">
<li class="subsection " role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="http://localhost:8000/courses/course-v1:W3Cx+HTML5.0x+1T2017/jump_to/block-v1:W3Cx+HTML5.0x+1T2017+type@sequential+block@77a74ef4daa74c83b00d0b1e0e6d81f6" id="block-v1:W3Cx+HTML5.0x+1T2017+type@sequential+block@77a74ef4daa74c83b00d0b1e0e6d81f6">
<div class="subsection-text">
<span class="subsection-title">Still Testing Subsection</span>
<div class="details">
</div> <!-- /details -->
</div> <!-- /subsection-text -->
<div class="subsection-actions">
</div>
</a>
</li>
</ol>
</li>
</ol>
</main>
</main>
<aside class="course-sidebar layout-col layout-col-a">
<div class="section section-tools">
<h3 class="hd-6 section-title">Course Tools</h3>
<ul class="list-unstyled">
<li>
<a class="course-tool-link" data-analytics-id="edx.bookmarks" href="/courses/course-v1:W3Cx+HTML5.0x+1T2017/bookmarks/">
<span class="icon fa fa-bookmark" aria-hidden="true"></span>
Bookmarks
</a>
</li>
<li>
<a class="course-tool-link" data-analytics-id="edx.updates" href="/courses/course-v1:W3Cx+HTML5.0x+1T2017/course/updates">
<span class="icon fa fa-newspaper-o" aria-hidden="true"></span>
Updates
</a>
</li>
</ul>
</div>
<div class="section section-upgrade">
<h3 class="hd hd-6">Pursue a verified certificate</h3>
<div class="upgrade-container">
<p>
<a class="btn-brand btn-upgrade"
href="${upgrade_url}"
data-creative="sidebarupsell"
data-position="sidebar-message">
Upgrade $49
</a>
</p>
<p><button class="btn-link btn-small promo-learn-more">Learn More</button></p>
</div>
<img src="https://courses.edx.org/static/images/edx-verified-mini-cert.png" alt="">
</div>
<div class="section section-dates">
<h3 class="hd hd-6 section-title handouts-header">Important Course Dates</h3>
<div class="date-summary-container">
<div class="date-summary date-summary-todays-date">
<span class="hd hd-6 heading localized-datetime" data-datetime="2017-07-13 17:31:27.952061+00:00" data-string="Today is {date}" data-timezone="None" data-language="en">Today is Jul 13, 2017 13:31 EDT</span>
</div>
</div>
</div>
</aside>
</div>
</div>
</div>

View File

@@ -1,131 +0,0 @@
<section class="course-outline" id="main">
<button class="btn btn-outline-primary pull-right"
id="expand-collapse-outline-all-button"
aria-expanded="false"
aria-controls="course-outline-block-tree"
>
<span class="expand-collapse-outline-all-extra-padding" id="expand-collapse-outline-all-span">${_("Expand All")}</span>
</button>
<ol class="block-tree" role="tree">
<li aria-expanded="true" class="outline-item focusable" id="block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b"
role="treeitem" tabindex="0">
<div class="section-name">
<span class="icon fa fa-chevron-down" aria-hidden="true"></span>
<span>Introduction</span>
</div>
<ol class="outline-item focusable" role="group" tabindex="0">
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction">
Demo Course Overview
</a>
</li>
</ol>
</li>
<li aria-expanded="true" class="outline-item focusable" id="block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations"
role="treeitem" tabindex="0">
<div class="section-name">
<span class="icon fa fa-chevron-down" aria-hidden="true"></span>
<span>Example Week 1: Getting Started</span>
</div>
<ol class="outline-item focusable" role="group" tabindex="0">
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5">
Lesson 1 - Getting Started
</a>
</li>
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions">
Homework - Question Styles
</a>
</li>
</ol>
</li>
<li aria-expanded="true" class="outline-item focusable" id="block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions"
role="treeitem" tabindex="0">
<div class="section-name">
<span class="icon fa fa-chevron-down" aria-hidden="true"></span>
<span>Example Week 2: Get Interactive</span>
</div>
<ol class="outline-item focusable" role="group" tabindex="0">
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations">
Lesson 2 - Let's Get Interactive!
</a>
</li>
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations">
Homework - Labs and Demos
</a>
</li>
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e">
Homework - Essays
</a>
</li>
</ol>
</li>
<li aria-expanded="true" class="outline-item focusable" id="block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration"
role="treeitem" tabindex="0">
<div class="section-name">
<span class="icon fa fa-chevron-down" aria-hidden="true"></span>
<span>Example Week 3: Be Social</span>
</div>
<ol class="outline-item focusable" role="group" tabindex="0">
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e">
Lesson 3 - Be Social
</a>
</li>
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc027bcb4fe9afb744d2e8415855"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc027bcb4fe9afb744d2e8415855">
Homework - Find Your Study Buddy
</a>
</li>
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@6ab9c442501d472c8ed200e367b4edfa"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@6ab9c442501d472c8ed200e367b4edfa">
More Ways to Connect
</a>
</li>
</ol>
</li>
<li aria-expanded="true" class="outline-item focusable" id="block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7"
role="treeitem" tabindex="0">
<div class="section-name">
<span class="icon fa fa-chevron-down" aria-hidden="true"></span>
<span>About Exams and Certificates</span>
</div>
<ol class="outline-item focusable" role="group" tabindex="0">
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow">
edX Exams
</a>
</li>
</ol>
</li>
<li aria-expanded="true" class="outline-item focusable" id="block-v1:edX+DemoX+Demo_Course+type@chapter+block@9fca584977d04885bc911ea76a9ef29e"
role="treeitem" tabindex="0">
<div class="section-name">
<span class="icon fa fa-chevron-down" aria-hidden="true"></span>
<span>holding section</span>
</div>
<ol class="outline-item focusable" role="group" tabindex="0">
<li role="treeitem" tabindex="-1" aria-expanded="true">
<a class="outline-item focusable" href="/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@07bc32474380492cb34f76e5f9d9a135"
id="block-v1:edX+DemoX+Demo_Course+type@sequential+block@07bc32474380492cb34f76e5f9d9a135">
New Subsection
</a>
</li>
</ol>
</li>
</ol>
</section>

View File

@@ -1 +0,0 @@
<button class="enroll-btn btn-link">Enroll Now</button>

View File

@@ -1,10 +0,0 @@
<div class="update-message">
<h3>Latest Update</h3>
<div class="dismiss-message">
<button type="button" class="btn-link">
<span class="sr">Dismiss</span>
<span class="icon fa fa-times" aria-hidden="true"></span>
</button>
</div>
This is an update.
</div>

View File

@@ -1,32 +0,0 @@
<div class="welcome-message">
<div class="dismiss-message">
<button type="button" class="btn-link">
<span class="sr">Dismiss</span>
<span class="icon fa fa-times" aria-hidden="true"></span>
</button>
</div>
<div id="welcome-message-content" class="welcome-message-content">
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!
</div>
<button type="button"
id="welcome-message-show-more"
class="btn btn-primary welcome-message-show-more"
aria-live="polite"
data-state="more"
hidden
>
Show More
</button>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -1,50 +0,0 @@
/* 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(`<div class="success-message">${successMsg}</div>`);
} 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(`<div class="error-message"> ${errorMsg} </div>`);
},
});
});
// Allow goal selection with an enter press for accessibility purposes
$('.goal-option').keypress((e) => {
if (e.which === 13) {
$(e.target).click();
}
});
}
}

View File

@@ -1,160 +0,0 @@
/* 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 });
});
}
}

View File

@@ -1,112 +0,0 @@
/* 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);
}
}
}

View File

@@ -1,45 +0,0 @@
/*
* 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));
}
}

View File

@@ -1,15 +0,0 @@
/* 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();
});
}
}

View File

@@ -1,65 +0,0 @@
/* 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();
});
}
}
}

View File

@@ -1,70 +0,0 @@
/* 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' });
});
});
});

View File

@@ -1,113 +0,0 @@
/* 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(),
});
});
});
});

View File

@@ -9,6 +9,8 @@ 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 = {
@@ -48,5 +50,10 @@ 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');
});
});
});

View File

@@ -1,48 +0,0 @@
/* 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();
});
});
});

View File

@@ -1,38 +0,0 @@
/* 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);
});
});
});

View File

@@ -1,107 +0,0 @@
/* 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');
});
});
});

View File

@@ -1,236 +0,0 @@
## 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">
<link rel="stylesheet" type="text/css" href="${static.url('paragon/static/paragon.min.css')}" />
</%block>
<%block name="content">
<div class="course-view page-content-container" id="course-container">
<header class="page-header has-secondary">
<div class="page-header-main">
<nav aria-label="${_('Course Outline')}" class="sr-is-focusable" tabindex="-1">
<h2 class="hd hd-3 page-title">${course.display_name_with_default}</h2>
</nav>
</div>
<div class="page-header-secondary">
% if show_search:
<div class="page-header-search">
<form class="search-form input-group" role="search" action="${reverse('openedx.course_search.course_search_results', args=[course_key])}">
<label class="field-label sr-only" for="search" id="search-hint">${_('Search the course')}</label>
<input
class="field-input input-text search-input form-control"
type="search"
name="query"
id="search"
placeholder="${_('Search the course')}"
/>
<span class="input-group-btn">
<button class="btn btn-outline-primary search-button" type="submit">${_('Search')}</button>
</span>
</form>
</div>
% endif
<div class="form-actions">
% if resume_course_url:
<a class="btn btn-primary action-resume-course" href="${resume_course_url}">
% if has_visited_course:
<span data-action-type="resume">${_("Resume Course")}</span>
% else:
<span data-action-type="start">${_("Start Course")}</span>
% endif
</a>
% endif
</div>
</div>
</header>
<div class="page-content">
<div class="page-content-main">
% 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):
<div class="section section-update-message">
${HTML(update_message_fragment.body_html())}
</div>
% endif
% if outline_fragment:
${HTML(outline_fragment.body_html())}
% endif
</div>
<aside class="page-content-secondary course-sidebar">
<div class="proctoring-info-panel"
data-course-id="${course_key}" data-username="${username}"></div>
% if has_goal_permission:
<div class="section section-goals ${'' if current_goal else 'hidden'}">
<div class="current-goal-container">
<label class="title title-label hd-6" for="goal">
<h3 class="hd-6">${_("Goal: ")}</h3>
</label>
<h3 class="title hd-6">${_("Goal: ")}</h3>
<div class="goal">
<span class="text">${goal_options[current_goal.goal_key] if current_goal else ""}</span>
</div>
<select class="edit-goal-select" id="goal">
% for goal, goal_text in goal_options.items():
<option value="${goal}" ${"selected" if current_goal and current_goal.goal_key == goal else ""}>${goal_text}</option>
% endfor
</select>
<span class="sr sr-update-response-msg" aria-live="polite"></span>
<span class="response-icon" aria-hidden="true"></span>
<span class="sr">${_("Edit your course goal:")}</span>
<button class="edit-icon">
<span class="sr">${_("Edit your course goal:")}</span>
<span class="fa fa-pencil" aria-hidden="true"></span>
</button>
</div>
</div>
% endif
% if course_tools:
<div class="section section-tools">
<h3 class="hd-6 section-title">${_("Course Tools")}</h3>
<ul class="list-unstyled">
% for course_tool in course_tools:
<li class="course-tool">
% if course_tool.http_method == HttpMethod.GET:
<a class="course-tool-link" data-analytics-id="${course_tool.analytics_id()}" href="${course_tool.url(course_key)}">
<span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span>${course_tool.title()}
</a>
% elif course_tool.http_method == HttpMethod.POST:
<form class="course-tool-form" action="${course_tool.url(course_key)}" method="post">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
<input type="hidden" name="tool_data" value="${course_tool.data()}">
<button class="course-tool-button" data-analytics-id="${course_tool.analytics_id()}" aria-hidden="true">
<span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span>
${course_tool.title()}
</button>
</form>
% endif
</li>
% endfor
</ul>
</div>
% endif
% if upgrade_url and upgrade_price:
<div class="section section-upgrade course-home-sidebar-upgrade ${'discount' if has_discount else 'no-discount'}">
<h3 class="hd hd-6">${_("Pursue a verified certificate")}</h3>
<img src="${static.url('images/edx-verified-mini-cert.png')}"
alt="${_('Sample verified certificate with your name, the course title, the logo of the institution and the signatures of the instructors for this course.')}" />
<div class="upgrade-container">
<p>
<a id="green_upgrade" class="btn-brand btn-upgrade"
href="${upgrade_url}"
data-creative="sidebarupsell"
data-position="sidebar-message"
>
${Text(_("Upgrade ({price})")).format(price=upgrade_price)}
</a>
</p>
<p><button class="btn-link btn-small promo-learn-more">${_('Learn More')}</button></p>
</div>
</div>
% endif
% if dates_fragment:
<div class="section section-dates">
${HTML(dates_fragment.body_html())}
</div>
% endif
% if handouts_html:
<div class="section section-handouts">
<h3 class="hd-6 section-title">${_("Course Handouts")}</h3>
${HTML(handouts_html)}
</div>
% endif
</aside>
</div>
% if course_sock_fragment:
${HTML(course_sock_fragment.body_html())}
% endif
</div>
</%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>

View File

@@ -1,40 +0,0 @@
## 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:
<div class="course-message">
% if not is_rtl:
<img class="message-author" alt="" src="${static.url(image_src)}"/>
% endif
<div class="message-content" aria-live="polite">
${HTML(message.message_html)}
</div>
% if is_rtl:
<img class="message-author" alt="" src="${static.url(image_src)}"/>
% endif
</div>
% 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>

View File

@@ -1,180 +0,0 @@
## 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
%>
<main role="main" class="course-outline" id="main" tabindex="-1">
<%include file="/dates_banner.html" />
% if course_sections is not None:
<button class="btn btn-outline-primary pull-right"
id="expand-collapse-outline-all-button"
aria-expanded="false"
aria-controls="course-outline-block-tree"
>
<span class="expand-collapse-outline-all-extra-padding" id="expand-collapse-outline-all-span">${_("Expand All")}</span>
</button>
<ol class="block-tree accordion"
id="course-outline-block-tree"
aria-labelledby="expand-collapse-outline-all-button">
% for section in course_sections:
<%
section_is_auto_opened = section.get('resume_block') is True
scored = 'scored' if section.get('scored', False) else ''
%>
<li class="outline-item section ${scored}">
<button class="section-name accordion-trigger outline-button"
aria-expanded="${ 'true' if section_is_auto_opened else 'false' }"
aria-controls="${ section['id'] }_contents"
id="${ section['id'] }">
<span class="fa fa-chevron-right ${ 'fa-rotate-90' if section_is_auto_opened else '' }" aria-hidden="true"></span>
<h3 class="section-title">${ section['display_name'] }</h3>
% if section.get('complete'):
<span class="complete-checkmark fa fa-check" aria-hidden="true"></span>
<span class="sr">${_("Completed")}</span>
% endif
</button>
<ol class="outline-item accordion-panel ${ '' if section_is_auto_opened else 'is-hidden' }"
id="${ section['id'] }_contents"
aria-labelledby="${ section['id'] }">
% 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)
%>
<li class="subsection accordion ${ 'current' if subsection.get('resume_block') else '' } ${graded} ${scored}">
<a
% if enable_links:
href="${ subsection['lms_web_url'] }"
% else:
aria-disabled="true"
% endif
class="subsection-text outline-button"
id="${ subsection['id'] }"
>
% if graded and scored and 'special_exam_info' not in subsection:
<span class="icon fa fa-pencil-square-o" aria-hidden="true"></span>
% endif
<h4 class="subsection-title">
${ subsection['display_name'] }
% if num_graded_problems:
${ngettext("({number} Question)",
"({number} Questions)",
num_graded_problems).format(number=num_graded_problems)}
% endif
</h4>
% if subsection.get('complete'):
<span class="complete-checkmark fa fa-check" aria-hidden="true"></span>
<span class="sr">${_("Completed")}</span>
% endif
% if needs_prereqs:
<div class="details prerequisite">
<span class="prerequisites-icon icon fa fa-lock" aria-hidden="true"></span>
${ _("Prerequisite: ") }
<%
prerequisite_id = gated_content[subsection['id']]['prerequisite']
prerequisite_name = xblock_display_names.get(prerequisite_id)
%>
${ prerequisite_name }
</div>
% endif
<div class="details">
## 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:
<span class="subtitle">
% if 'special_exam_info' in subsection:
## Display the exam status icon and status message
<span
class="menu-icon icon fa ${subsection['special_exam_info'].get('suggested_icon', 'fa-pencil-square-o')} ${subsection['special_exam_info'].get('status', 'eligible')}"
aria-hidden="true"
></span>
<span class="subtitle-name">
${subsection['special_exam_info'].get('short_description', '')}
</span>
## 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):
<span
class="localized-datetime subtitle-name"
data-datetime="${data_datetime}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% endif
% else:
## non-graded section, we just show the exam format and the due date
## this is the standard case in edx-platform
<span
class="localized-datetime subtitle-name"
data-datetime="${data_datetime}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% if subsection.get('graded'):
<span class="sr">&nbsp;${_("This content is graded")}</span>
% endif
% endif
</span>
% endif
</div> <!-- /details -->
</a>
</li>
% endfor
</ol>
</li>
% endfor
</ol>
% endif
</main>
<%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>

View File

@@ -1,27 +0,0 @@
## 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">
<div class="update-message">
<div class="dismiss-message">
<button type="button" class="btn-link">
<span class="sr">${_("Dismiss")}</span>
<span class="icon fa fa-times" aria-hidden="true"></span>
</button>
</div>
<h3>${_("Latest Update")}</h3>
${HTML(update_html)}
</div>
</%block>
<%static:webpack entry="LatestUpdate">
new LatestUpdate( { messageContainer: '.update-message', dismissButton: '.dismiss-message button'});
</%static:webpack>

View File

@@ -1,41 +0,0 @@
## 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">
<div class="welcome-message">
<div class="dismiss-message">
<button type="button" class="btn-link">
<span class="sr">${_("Dismiss")}</span>
<span class="icon fa fa-times" aria-hidden="true"></span>
</button>
</div>
<div id="welcome-message-content" class="welcome-message-content">
${HTML(welcome_message_html)}
</div>
<button type="button"
id="welcome-message-show-more"
class="btn btn-primary welcome-message-show-more"
aria-live="polite"
data-state="more"
hidden
>
${_("Show More")}
</button>
</div>
</%block>
<%static:webpack entry="WelcomeMessage">
new WelcomeMessage({
dismissUrl: "${dismiss_url | n, js_escaped_string}",
});
</%static:webpack>

View File

@@ -1,48 +0,0 @@
"""
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

View File

@@ -1,932 +1,16 @@
"""
Tests for the course home page.
Tests for the legacy course home page.
"""
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 = '<div class="course-expiration-message">'
TEST_WELCOME_MESSAGE = '<h2>Welcome!</h2>'
TEST_UPDATE_MESSAGE = '<h2>Test Update!</h2>'
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
from django.test import TestCase
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
class TestCourseHomePage(TestCase):
"""Tests for the legacy course home page (the legacy course outline tab)"""
def test_legacy_redirect(self):
"""
Verify that the legacy course home page redirects to the MFE correctly.
"""
url = course_home_url(self.course) + '?foo=b$r'
response = self.client.get(url)
response = self.client.get('/courses/course-v1:edX+test+Test_Course/course/?foo=b$r')
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, '<div class="user-messages"', count=(1 if expected_enroll_message else 0))
if expected_enroll_message:
self.assertContains(response, 'You must be enrolled in the course to see course content.')
if enable_unenrolled_access and course_visibility == COURSE_VISIBILITY_PUBLIC:
if user_type == CourseUserType.UNENROLLED and self.private_course.invitation_only:
if expected_enroll_message:
self.assertContains(private_response,
'You must be enrolled in the course to see course content.')
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=True)
@ddt.data(
[CourseUserType.ANONYMOUS, 'To see course content'],
[CourseUserType.ENROLLED, None],
[CourseUserType.UNENROLLED, 'You must be enrolled in the course to see course content.'],
[CourseUserType.UNENROLLED_STAFF, 'You must be enrolled in the course to see course content.'],
)
@ddt.unpack
def test_home_page_not_unified(self, user_type, expected_message):
"""
Verifies the course home tab when not unified.
"""
self.create_user_for_course(self.course, user_type)
# Render the course home page
url = course_home_url(self.course)
response = self.client.get(url)
# Verify that welcome messages are never shown
self.assertNotContains(response, TEST_WELCOME_MESSAGE)
# Verify that the outline, start button, course sock, course tools, and welcome message
# are only shown to enrolled users or unenrolled staff.
is_enrolled = user_type is CourseUserType.ENROLLED
is_unenrolled_staff = user_type is CourseUserType.UNENROLLED_STAFF
expected_count = 1 if (is_enrolled or is_unenrolled_staff) else 0
self.assertContains(response, TEST_CHAPTER_NAME, count=expected_count)
self.assertContains(response, 'Start Course', count=expected_count)
self.assertContains(response, TEST_COURSE_TOOLS, count=expected_count)
self.assertContains(response, 'Learn About Verified Certificate', count=(1 if is_enrolled else 0))
# Verify that the expected message is shown to the user
self.assertContains(response, '<div class="user-messages"', count=1 if expected_message else 0)
if expected_message:
self.assertContains(response, expected_message)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
def test_sign_in_button(self):
"""
Verify that the sign in button will return to this page.
"""
url = course_home_url(self.course)
response = self.client.get(url)
self.assertContains(response, f'/login?next={quote_plus(url)}')
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
def test_non_live_course(self):
"""
Ensure that a user accessing a non-live course sees a redirect to
the student dashboard, not a 404.
"""
future_course = self.create_future_course()
self.create_user_for_course(future_course, CourseUserType.ENROLLED)
url = course_home_url(future_course)
response = self.client.get(url)
start_date = strftime_localized(future_course.start, 'SHORT_DATE')
expected_params = QueryDict(mutable=True)
expected_params['notlive'] = start_date
expected_url = '{url}?{params}'.format(
url=reverse('dashboard'),
params=expected_params.urlencode()
)
self.assertRedirects(response, expected_url)
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
def test_course_does_not_expire_for_verified_user(self):
"""
There are a number of different roles/users that should not lose access after the expiration date.
Ensure that users who should not lose access get a 200 (ok) response
when attempting to visit the course after their would be expiration date.
"""
course = CourseFactory.create(start=THREE_YEARS_AGO)
url = course_home_url(course)
user = UserFactory.create(password=self.TEST_PASSWORD)
CourseEnrollment.enroll(user, self.course.id, mode=CourseMode.VERIFIED)
Schedule.objects.update(start_date=THREE_YEARS_AGO)
# ensure that the user who has indefinite access
self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(url)
assert response.status_code == 200, 'Should not expire access for user'
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
@ddt.data(
InstructorFactory,
StaffFactory,
BetaTesterFactory,
OrgStaffFactory,
OrgInstructorFactory,
)
def test_course_does_not_expire_for_course_staff(self, role_factory):
"""
There are a number of different roles/users that should not lose access after the expiration date.
Ensure that users who should not lose access get a 200 (ok) response
when attempting to visit the course after their would be expiration date.
"""
course = CourseFactory.create(start=THREE_YEARS_AGO)
url = course_home_url(course)
user = role_factory.create(password=self.TEST_PASSWORD, course_key=course.id)
CourseEnrollment.enroll(user, self.course.id, mode=CourseMode.AUDIT)
Schedule.objects.update(start_date=THREE_YEARS_AGO)
# ensure that the user has indefinite access
self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(url)
assert response.status_code == 200, 'Should not expire access for user'
@ddt.data(
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_ADMINISTRATOR
)
def test_course_does_not_expire_for_user_with_course_role(self, role_name):
"""
Test that users with the above roles for a course do not lose access
"""
course = CourseFactory.create(start=THREE_YEARS_AGO)
url = course_home_url(course)
user = UserFactory.create()
role = RoleFactory(name=role_name, course_id=course.id)
role.users.add(user)
# ensure the user has indefinite access
self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(url)
assert response.status_code == 200, 'Should not expire access for user'
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
@ddt.data(
GlobalStaffFactory,
)
def test_course_does_not_expire_for_global_users(self, role_factory):
"""
There are a number of different roles/users that should not lose access after the expiration date.
Ensure that users who should not lose access get a 200 (ok) response
when attempting to visit the course after their would be expiration date.
"""
course = CourseFactory.create(start=THREE_YEARS_AGO)
url = course_home_url(course)
user = role_factory.create(password=self.TEST_PASSWORD)
CourseEnrollment.enroll(user, self.course.id, mode=CourseMode.AUDIT)
Schedule.objects.update(start_date=THREE_YEARS_AGO)
# ensure that the user who has indefinite access
self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(url)
assert response.status_code == 200, 'Should not expire access for user'
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
def test_expired_course(self):
"""
Ensure that a user accessing an expired course sees a redirect to
the student dashboard, not a 404.
"""
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2010, 1, 1, tzinfo=UTC))
course = CourseFactory.create(start=THREE_YEARS_AGO)
url = course_home_url(course)
for mode in [CourseMode.AUDIT, CourseMode.VERIFIED]:
CourseModeFactory.create(course_id=course.id, mode_slug=mode)
# assert that an if an expired audit user tries to access the course they are redirected to the dashboard
audit_user = UserFactory(password=self.TEST_PASSWORD)
self.client.login(username=audit_user.username, password=self.TEST_PASSWORD)
audit_enrollment = CourseEnrollment.enroll(audit_user, course.id, mode=CourseMode.AUDIT)
audit_enrollment.created = THREE_YEARS_AGO + timedelta(days=1)
audit_enrollment.save()
response = self.client.get(url)
expiration_date = strftime_localized(course.start + timedelta(weeks=4) + timedelta(days=1), 'SHORT_DATE')
expected_params = QueryDict(mutable=True)
course_name = CourseOverview.get_from_id(course.id).display_name_with_default
expected_params['access_response_error'] = 'Access to {run} expired on {expiration_date}'.format(
run=course_name,
expiration_date=expiration_date
)
expected_url = '{url}?{params}'.format(
url=reverse('dashboard'),
params=expected_params.urlencode()
)
self.assertRedirects(response, expected_url)
def test_old_mongo_access_error(self):
"""
Ensure that a user accessing an Old Mongo course sees a redirect to
the student dashboard, not a 404.
"""
course = CourseFactory.create(default_store=ModuleStoreEnum.Type.mongo)
user = UserFactory(password=self.TEST_PASSWORD)
self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(course_home_url(course))
expected_params = QueryDict(mutable=True)
expected_params['access_response_error'] = f'{course.display_name_with_default} is no longer available.'
expected_url = '{url}?{params}'.format(
url=reverse('dashboard'),
params=expected_params.urlencode(),
)
self.assertRedirects(response, expected_url)
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
def test_expiration_banner_with_expired_upgrade_deadline(self):
"""
Ensure that a user accessing a course with an expired upgrade deadline
will still see the course expiration banner without the upgrade related text.
"""
past = datetime(2010, 1, 1, tzinfo=UTC)
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=past)
course = CourseFactory.create(start=now() - timedelta(days=10))
CourseModeFactory.create(course_id=course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=course.id, mode_slug=CourseMode.VERIFIED, expiration_datetime=past)
user = UserFactory(password=self.TEST_PASSWORD)
self.client.login(username=user.username, password=self.TEST_PASSWORD)
CourseEnrollment.enroll(user, course.id, mode=CourseMode.AUDIT)
url = course_home_url(course)
response = self.client.get(url)
bannerText = get_expiration_banner_text(user, course)
self.assertContains(response, bannerText, html=True)
self.assertContains(response, TEST_BANNER_CLASS)
def test_audit_only_not_expired(self):
"""
Verify that enrolled users are NOT shown the course expiration banner and can
access the course home page if course audit only
"""
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2010, 1, 1, tzinfo=UTC))
audit_only_course = CourseFactory.create()
self.create_user_for_course(audit_only_course, CourseUserType.ENROLLED)
response = self.client.get(course_home_url(audit_only_course))
assert response.status_code == 200
self.assertContains(response, TEST_COURSE_TOOLS)
self.assertNotContains(response, TEST_BANNER_CLASS)
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
def test_expired_course_in_holdback(self):
"""
Ensure that a user accessing an expired course that is in the holdback
does not get redirected to the student dashboard, not a 404.
"""
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2010, 1, 1, tzinfo=UTC))
course = CourseFactory.create(start=THREE_YEARS_AGO)
url = course_home_url(course)
for mode in [CourseMode.AUDIT, CourseMode.VERIFIED]:
CourseModeFactory.create(course_id=course.id, mode_slug=mode)
# assert that an if an expired audit user in the holdback tries to access the course
# they are not redirected to the dashboard
audit_user = UserFactory(password=self.TEST_PASSWORD)
self.client.login(username=audit_user.username, password=self.TEST_PASSWORD)
audit_enrollment = CourseEnrollment.enroll(audit_user, course.id, mode=CourseMode.AUDIT)
Schedule.objects.update(start_date=THREE_YEARS_AGO)
FBEEnrollmentExclusion.objects.create(
enrollment=audit_enrollment
)
response = self.client.get(url)
assert response.status_code == 200
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
@mock.patch("common.djangoapps.util.date_utils.strftime_localized")
def test_non_live_course_other_language(self, mock_strftime_localized):
"""
Ensure that a user accessing a non-live course sees a redirect to
the student dashboard, not a 404, even if the localized date is unicode
"""
future_course = self.create_future_course()
self.create_user_for_course(future_course, CourseUserType.ENROLLED)
fake_unicode_start_time = "üñîçø∂é_ßtå®t_tîµé"
mock_strftime_localized.return_value = fake_unicode_start_time
url = course_home_url(future_course)
response = self.client.get(url)
expected_params = QueryDict(mutable=True)
expected_params['notlive'] = fake_unicode_start_time
expected_url = '{url}?{params}'.format(
url=reverse('dashboard'),
params=expected_params.urlencode()
)
self.assertRedirects(response, expected_url)
def test_nonexistent_course(self):
"""
Ensure a non-existent course results in a 404.
"""
self.create_user_for_course(self.course, CourseUserType.ANONYMOUS)
url = course_home_url_from_string('not/a/course')
response = self.client.get(url)
assert response.status_code == 404
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True)
@override_settings(PLATFORM_NAME="edX")
def test_masters_course_message(self):
enroll_button_html = "<button class=\"enroll-btn btn-link\">Enroll now</button>"
# 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, '<a id="green_upgrade" class="btn-brand btn-upgrade"')
self.assertContains(response, url)
self.assertContains(
response,
f"Upgrade (<span class='price'>${self.verified_mode.min_price}</span>)",
)
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("<span>DISCOUNT_PRICE</span>"), 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, "<span>DISCOUNT_PRICE</span>")

View File

@@ -1,767 +0,0 @@
"""
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*</h4>')
self.assertRegex(content, sequential2.display_name + r'\s*\(1 Question\)\s*</h4>')
self.assertRegex(content, sequential3.display_name + r'\s*\(2 Questions\)\s*</h4>')
@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, '<div class="banner-cta-text"')
else:
self.assertNotContains(response, '<div class="banner-cta-text"')
@override_waffle_flag(RELATIVE_DATES_FLAG, active=True)
def test_reset_course_deadlines(self):
course = self.courses[0]
staff = StaffFactory(course_key=course.id)
CourseEnrollment.enroll(staff, course.id)
start_date = timezone.now() - datetime.timedelta(days=30)
Schedule.objects.update(start_date=start_date)
self.client.login(username=staff.username, password=TEST_PASSWORD)
self.update_masquerade(course=course, username=self.user.username)
post_dict = {'course_id': str(course.id)}
self.client.post(reverse(RESET_COURSE_DEADLINES_NAME), post_dict)
updated_schedule = Schedule.objects.get(enrollment__user=self.user, enrollment__course_id=course.id)
assert updated_schedule.start_date.date() == datetime.datetime.today().date()
updated_staff_schedule = Schedule.objects.get(enrollment__user=staff, enrollment__course_id=course.id)
assert updated_staff_schedule.start_date == start_date
@override_waffle_flag(RELATIVE_DATES_FLAG, active=True)
def test_reset_course_deadlines_masquerade_generic_student(self):
course = self.courses[0]
staff = StaffFactory(course_key=course.id)
CourseEnrollment.enroll(staff, course.id)
start_date = timezone.now() - datetime.timedelta(days=30)
Schedule.objects.update(start_date=start_date)
self.client.login(username=staff.username, password=TEST_PASSWORD)
self.update_masquerade(course=course)
post_dict = {'course_id': str(course.id)}
self.client.post(reverse(RESET_COURSE_DEADLINES_NAME), post_dict)
updated_student_schedule = Schedule.objects.get(enrollment__user=self.user, enrollment__course_id=course.id)
assert updated_student_schedule.start_date == start_date
updated_staff_schedule = Schedule.objects.get(enrollment__user=staff, enrollment__course_id=course.id)
assert updated_staff_schedule.start_date.date() == datetime.date.today()
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
"""
Test the course outline view with prerequisites.
"""
TRANSFORMER_CLASS_TO_TEST = MilestonesAndSpecialExamsTransformer
@classmethod
def setUpClass(cls):
"""
Creates a test course that can be used for non-destructive tests
"""
# pylint: disable=super-method-not-called
cls.PREREQ_REQUIRED = '(Prerequisite required)'
cls.UNLOCKED = 'Unlocked'
with super().setUpClassAndTestData():
cls.course, cls.course_blocks = cls.create_test_course()
@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)
CourseEnrollment.enroll(cls.user, cls.course.id)
@classmethod
def create_test_course(cls):
"""Creates a test course."""
course = CourseFactory.create()
course.enable_subsection_gating = True
course_blocks = {}
with cls.store.bulk_operations(course.id):
course_blocks['chapter'] = ItemFactory.create(
category='chapter',
parent_location=course.location
)
course_blocks['prerequisite'] = ItemFactory.create(
category='sequential',
parent_location=course_blocks['chapter'].location,
display_name='Prerequisite Exam'
)
course_blocks['gated_content'] = ItemFactory.create(
category='sequential',
parent_location=course_blocks['chapter'].location,
display_name='Gated Content'
)
course_blocks['prerequisite_vertical'] = ItemFactory.create(
category='vertical',
parent_location=course_blocks['prerequisite'].location
)
course_blocks['gated_content_vertical'] = ItemFactory.create(
category='vertical',
parent_location=course_blocks['gated_content'].location
)
course.children = [course_blocks['chapter']]
course_blocks['chapter'].children = [course_blocks['prerequisite'], course_blocks['gated_content']]
course_blocks['prerequisite'].children = [course_blocks['prerequisite_vertical']]
course_blocks['gated_content'].children = [course_blocks['gated_content_vertical']]
if hasattr(cls, 'user'):
CourseEnrollment.enroll(cls.user, course.id)
return course, course_blocks
def setUp(self):
"""
Set up for the tests.
"""
super().setUp()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
def setup_gated_section(self, gated_block, gating_block):
"""
Test helper to create a gating requirement
Args:
gated_block: The block the that learner will not have access to until they complete the gating block
gating_block: (The prerequisite) The block that must be completed to get access to the gated block
"""
gating_api.add_prerequisite(self.course.id, str(gating_block.location))
gating_api.set_required_content(self.course.id, gated_block.location, gating_block.location, 100)
def test_content_locked(self):
"""
Test that a sequential/subsection with unmet prereqs correctly indicated that its content is locked
"""
course = self.course
self.setup_gated_section(self.course_blocks['gated_content'], self.course_blocks['prerequisite'])
response = self.client.get(course_home_url(course))
assert response.status_code == 200
response_content = pq(response.content)
# check lock icon is present
lock_icon = response_content('.fa-lock')
assert lock_icon, 'lock icon is not present, but should be'
subsection = lock_icon.parents('.subsection-text')
# check that subsection-title-name is the display name
gated_subsection_title = self.course_blocks['gated_content'].display_name
assert gated_subsection_title in subsection.children('.subsection-title').html()
# check that it says prerequisite required
assert 'Prerequisite:' in subsection.children('.details').html()
# check that there is not a screen reader message
assert not subsection.children('.sr')
def test_content_unlocked(self):
"""
Test that a sequential/subsection with met prereqs correctly indicated that its content is unlocked
"""
course = self.course
self.setup_gated_section(self.course_blocks['gated_content'], self.course_blocks['prerequisite'])
# complete the prerequisite to unlock the gated content
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
with patch('openedx.core.lib.gating.api.get_subsection_completion_percentage', Mock(return_value=100)):
lms_gating_api.evaluate_prerequisite(
self.course,
Mock(location=self.course_blocks['prerequisite'].location, percent_graded=1.0),
self.user,
)
response = self.client.get(course_home_url(course))
assert response.status_code == 200
response_content = pq(response.content)
# check unlock icon is not present
unlock_icon = response_content('.fa-unlock')
assert not unlock_icon, "unlock icon is present, yet shouldn't be."
gated_subsection_title = self.course_blocks['gated_content'].display_name
every_subsection_on_outline = response_content('.subsection-title')
subsection_has_gated_text = False
says_prerequisite_required = False
for subsection_contents in every_subsection_on_outline.contents():
subsection_has_gated_text = gated_subsection_title in subsection_contents
says_prerequisite_required = "Prerequisite:" in subsection_contents
# check that subsection-title-name is the display name of gated content section
assert subsection_has_gated_text
assert not says_prerequisite_required
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleTestMixin):
"""
Test start course and resume course for the course outline view.
Technically, this mixes course home and course outline tests, but checking
the counts of start/resume course should be done together to avoid false
positives.
"""
@classmethod
def setUpClass(cls):
"""
Creates a test course that can be used for non-destructive tests
"""
# setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
cls.course = cls.create_test_course()
@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)
CourseEnrollment.enroll(cls.user, cls.course.id)
cls.site = Site.objects.get_current()
@classmethod
def create_test_course(cls):
"""
Creates a test course.
"""
course = CourseFactory.create()
with cls.store.bulk_operations(course.id):
chapter = ItemFactory.create(category='chapter', parent_location=course.location)
chapter2 = 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)
sequential3 = ItemFactory.create(category='sequential', parent_location=chapter2.location)
sequential4 = ItemFactory.create(category='sequential', parent_location=chapter2.location)
vertical = ItemFactory.create(category='vertical', parent_location=sequential.location)
vertical2 = ItemFactory.create(category='vertical', parent_location=sequential2.location)
vertical3 = ItemFactory.create(category='vertical', parent_location=sequential3.location)
vertical4 = ItemFactory.create(category='vertical', parent_location=sequential4.location)
problem = ItemFactory.create(category='problem', parent_location=vertical.location)
problem2 = ItemFactory.create(category='problem', parent_location=vertical2.location)
problem3 = ItemFactory.create(category='problem', parent_location=vertical3.location)
course.children = [chapter, chapter2]
chapter.children = [sequential, sequential2]
chapter2.children = [sequential3, sequential4]
sequential.children = [vertical]
sequential2.children = [vertical2]
sequential3.children = [vertical3]
sequential4.children = [vertical4]
vertical.children = [problem]
vertical2.children = [problem2]
vertical3.children = [problem3]
if hasattr(cls, 'user'):
CourseEnrollment.enroll(cls.user, course.id)
return course
def setUp(self):
"""
Set up for the tests.
"""
super().setUp()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
def visit_sequential(self, course, chapter, sequential):
"""
Navigates to the provided sequential.
"""
last_accessed_url = reverse(
'courseware_section',
kwargs={
'course_id': str(course.id),
'chapter': chapter.url_name,
'section': sequential.url_name,
}
)
assert 200 == self.client.get(last_accessed_url).status_code
@override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=True)
def complete_sequential(self, course, sequential):
"""
Completes provided sequential.
"""
course_key = CourseKey.from_string(str(course.id))
# Fake a visit to sequence2/vertical2
block_key = UsageKey.from_string(str(sequential.location))
if block_key.course_key.run is None:
# Old mongo keys must be annotated with course run info before calling submit_completion:
block_key = block_key.replace(course_key=course_key)
completion = 1.0
BlockCompletion.objects.submit_completion(
user=self.user,
block_key=block_key,
completion=completion
)
def visit_course_home(self, course, start_count=0, resume_count=0):
"""
Helper function to navigates to course home page, test for resume buttons
:param course: course factory object
:param start_count: number of times 'Start Course' should appear
:param resume_count: number of times 'Resume Course' should appear
:return: response object
"""
response = self.client.get(course_home_url(course))
assert response.status_code == 200
self.assertContains(response, 'Start Course', count=start_count)
self.assertContains(response, 'Resume Course', count=resume_count)
return response
def test_course_home_completion(self):
"""
Test that completed blocks appear checked on course home page
"""
self.override_waffle_switch(True)
course = self.course
vertical = course.children[0].children[0].children[0]
response = self.client.get(course_home_url(course))
content = pq(response.content)
assert len(content('.fa-check')) == 0
self.complete_sequential(self.course, vertical)
response = self.client.get(course_home_url(course))
content = pq(response.content)
# Subsection should be checked. Subsection 4 is also checked because it contains a vertical with no content
assert len(content('.fa-check')) == 2
def test_start_course(self):
"""
Tests that the start course button appears when the course has never been accessed.
Technically, this is a course home test, and not a course outline test, but checking the counts of
start/resume course should be done together to not get a false positive.
"""
course = self.course
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_settings(LMS_BASE='test_url:9999')
@override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True)
def test_resume_course_with_completion_api(self):
"""
Tests completion API resume button functionality
"""
self.override_waffle_switch(True)
# Course tree
course = self.course
problem1 = course.children[0].children[0].children[0].children[0]
problem2 = course.children[0].children[1].children[0].children[0]
self.complete_sequential(self.course, problem1)
# Test for 'resume' link
response = self.visit_course_home(course, resume_count=1)
# Test for 'resume' link URL - should be problem 1
content = pq(response.content)
assert content('.action-resume-course').attr('href').endswith('+type@problem+block@' + problem1.url_name)
self.complete_sequential(self.course, problem2)
# Test for 'resume' link
response = self.visit_course_home(course, resume_count=1)
# Test for 'resume' link URL - should be problem 2
content = pq(response.content)
assert content('.action-resume-course').attr('href').endswith('+type@problem+block@' + problem2.url_name)
# visit sequential 1, make sure 'Resume Course' URL is robust against 'Last Visited'
# (even though I visited seq1/vert1, 'Resume Course' still points to seq2/vert2)
self.visit_sequential(course, course.children[0], course.children[0].children[0])
# Test for 'resume' link URL - should be problem 2 (last completed block, NOT last visited)
response = self.visit_course_home(course, resume_count=1)
content = pq(response.content)
assert content('.action-resume-course').attr('href').endswith('+type@problem+block@' + problem2.url_name)
def test_resume_course_deleted_sequential(self):
"""
Tests resume course when the last completed sequential is deleted and
there is another sequential 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]
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 "<olclass=\"outline-itemaccordion-panel" + is_hidden_string + "\"" \
"id=\"" + url + "_contents\"" \
"aria-labelledby=\"" + url + "\"" \
">"
# 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')

View File

@@ -6,11 +6,12 @@ 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.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from lms.djangoapps.courseware.toggles import COURSEWARE_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
@@ -18,14 +19,13 @@ 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 = '<div class="verification-sock"'
@ddt.ddt
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True)
class TestCourseSockView(SharedModuleStoreTestCase):
"""
Tests for the course verification sock fragment view.
@@ -62,13 +62,16 @@ class TestCourseSockView(SharedModuleStoreTestCase):
# Log the user in
self.client.login(username=self.user.username, password=TEST_PASSWORD)
def get_courseware(self, course):
return self.client.get(reverse('courseware', kwargs={'course_id': str(course.id)}))
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
def test_standard_course(self):
"""
Ensure that a course that cannot be verified does
not have a visible verification sock.
"""
response = self.client.get(course_home_url(self.standard_course))
response = self.get_courseware(self.standard_course)
self.assert_verified_sock_is_not_visible(self.standard_course, response)
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
@@ -77,7 +80,7 @@ class TestCourseSockView(SharedModuleStoreTestCase):
Ensure that a course that can be verified has a
visible verification sock.
"""
response = self.client.get(course_home_url(self.verified_course))
response = self.get_courseware(self.verified_course)
self.assert_verified_sock_is_visible(self.verified_course, response)
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
@@ -86,7 +89,7 @@ class TestCourseSockView(SharedModuleStoreTestCase):
Ensure that a course that has an expired upgrade
date does not display the verification sock.
"""
response = self.client.get(course_home_url(self.verified_course_update_expired))
response = self.get_courseware(self.verified_course_update_expired)
self.assert_verified_sock_is_not_visible(self.verified_course_update_expired, response)
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
@@ -95,7 +98,7 @@ class TestCourseSockView(SharedModuleStoreTestCase):
Ensure that a user that has already upgraded to a
verified status cannot see the verification sock.
"""
response = self.client.get(course_home_url(self.verified_course_already_enrolled))
response = self.get_courseware(self.verified_course_already_enrolled)
self.assert_verified_sock_is_not_visible(self.verified_course_already_enrolled, response)
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
@@ -104,7 +107,7 @@ class TestCourseSockView(SharedModuleStoreTestCase):
mock.Mock(return_value=(HTML("<span>DISCOUNT_PRICE</span>"), True))
)
def test_upgrade_message_discount(self):
response = self.client.get(course_home_url(self.verified_course))
response = self.get_courseware(self.verified_course)
self.assertContains(response, "<span>DISCOUNT_PRICE</span>")
def assert_verified_sock_is_visible(self, course, response): # lint-amnesty, pylint: disable=unused-argument

View File

@@ -2,10 +2,12 @@
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 openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG, SHOW_UPGRADE_MSG_ON_COURSE_HOME
from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND
from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG
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
@@ -14,11 +16,9 @@ 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,17 +67,15 @@ class TestVerifiedUpgradesWithMasquerade(MasqueradeTestBase):
Tests for the course verification upgrade messages while the user is being masqueraded.
"""
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(COURSEWARE_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(course_home_url(self.verified_course))
response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.verified_course.id)}))
self.assertContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False)
self.assertContains(response, UPGRADE_MESSAGE_CONTAINER, html=False)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(COURSEWARE_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(
@@ -86,11 +84,10 @@ 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(course_home_url(self.verified_course))
response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.verified_course.id)}))
self.assertNotContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False)
self.assertNotContains(response, UPGRADE_MESSAGE_CONTAINER, html=False)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(COURSEWARE_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(
@@ -99,6 +96,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(course_home_url(self.masters_course))
response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.masters_course.id)}))
self.assertNotContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False)
self.assertNotContains(response, UPGRADE_MESSAGE_CONTAINER, html=False)

View File

@@ -1,80 +0,0 @@
"""
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

View File

@@ -3,42 +3,10 @@ Defines URLs for the course experience.
"""
from django.urls import path
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'
from .views.course_home import outline_tab
from .views.course_updates import CourseUpdatesView
urlpatterns = [
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,
),
path('', outline_tab), # a now-removed legacy view, redirects to MFE
path('updates', CourseUpdatesView.as_view(), name='openedx.course_experience.course_updates'),
]

View File

@@ -128,23 +128,6 @@ 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.

View File

@@ -3,11 +3,7 @@ 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
@@ -44,35 +40,3 @@ 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')

View File

@@ -2,243 +2,13 @@
Views for the course home page.
"""
from django.shortcuts import redirect
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 = '<ol></ol>'
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
class CourseHomeView(CourseTabView):
"""
The home page for a course.
"""
@method_decorator(ensure_csrf_cookie)
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
@method_decorator(ensure_valid_course_key)
@method_decorator(add_maintenance_banner)
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)
if course_home_legacy_is_active(course.id) or request.user.is_staff:
home_fragment_view = CourseHomeFragmentView()
return home_fragment_view.render_to_fragment(request, course_id=course_id, **kwargs)
microfrontend_url = get_learning_mfe_home_url(course_key=course_id, url_fragment='home', params=request.GET)
raise Redirect(microfrontend_url)
class CourseHomeFragmentView(EdxFragmentView):
"""
A fragment to render the home page for a course.
"""
def _get_resume_course_info(self, request, course_id):
"""
Returns information relevant to resume course functionality.
Returns a tuple: (has_visited_course, resume_course_url)
has_visited_course: True if the user has ever completed a block, False otherwise.
resume_course_url: The URL of the 'resume course' block if the user has completed a block,
otherwise the URL of the first block to start the course.
"""
course_outline_root_block = get_course_outline_block_tree(request, course_id, request.user)
resume_block = get_resume_block(course_outline_root_block) if course_outline_root_block else None
has_visited_course = bool(resume_block)
if resume_block:
resume_course_url = resume_block['lms_web_url']
else:
start_block = get_start_block(course_outline_root_block) if course_outline_root_block else None
resume_course_url = start_block['lms_web_url'] if start_block else None
return has_visited_course, resume_course_url
def _get_course_handouts(self, request, course):
"""
Returns the handouts for the specified course.
"""
handouts = get_course_info_section(request, request.user, course, 'handouts')
if not handouts or handouts == EMPTY_HANDOUTS_HTML:
return None
return handouts
def render_to_fragment(self, request, course_id=None, **kwargs): # lint-amnesty, pylint: disable=arguments-differ, too-many-statements
"""
Renders the course's home page as a fragment.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key)
# Render the course dates as a fragment
dates_fragment = CourseDatesFragmentView().render_to_fragment(request, course_id=course_id, **kwargs)
# Render the full content to enrolled users, as well as to course and global staff.
# Unenrolled users who are not course or global staff are given only a subset.
enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
user_access = {
'is_anonymous': request.user.is_anonymous,
'is_enrolled': enrollment and enrollment.is_active,
'is_staff': has_access(request.user, 'staff', course_key),
}
allow_anonymous = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key)
allow_public = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC
allow_public_outline = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE
# Set all the fragments
outline_fragment = None
update_message_fragment = None
course_sock_fragment = None
course_expiration_fragment = None
has_visited_course = None
resume_course_url = None
handouts_html = None
course_overview = CourseOverview.get_from_id(course.id)
if user_access['is_enrolled'] or user_access['is_staff']:
outline_fragment = CourseOutlineFragmentView().render_to_fragment(
request, course_id=course_id, **kwargs
)
if LATEST_UPDATE_FLAG.is_enabled(course_key):
update_message_fragment = LatestUpdateFragmentView().render_to_fragment(
request, course_id=course_id, **kwargs
)
else:
update_message_fragment = WelcomeMessageFragmentView().render_to_fragment(
request, course_id=course_id, **kwargs
)
course_sock_fragment = CourseSockFragmentView().render_to_fragment(
request, course=course, **kwargs
)
has_visited_course, resume_course_url = self._get_resume_course_info(request, course_id)
handouts_html = self._get_course_handouts(request, course)
course_expiration_fragment = generate_course_expired_fragment(
request.user,
course_overview
)
elif allow_public_outline or allow_public:
outline_fragment = CourseOutlineFragmentView().render_to_fragment(
request, course_id=course_id, user_is_enrolled=False, **kwargs
)
course_sock_fragment = CourseSockFragmentView().render_to_fragment(request, course=course, **kwargs)
if allow_public:
handouts_html = self._get_course_handouts(request, course)
else:
# Redirect the user to the dashboard if they are not enrolled and
# this is a course that does not support direct enrollment.
if not can_self_enroll_in_course(course_key):
raise CourseAccessRedirect(reverse('dashboard'))
# Get the course tools enabled for this user and course
course_tools = CourseToolsPluginManager.get_enabled_course_tools(request, course_key)
# Check if the user can access the course goal functionality
has_goal_permission = has_course_goal_permission(request, course_id, user_access)
# Grab the current course goal and the acceptable course goal keys mapped to translated values
current_goal = get_course_goal(request.user, course_key)
goal_options = get_course_goal_options()
# Get the course goals api endpoint
goal_api_url = get_goal_api_url(request)
# Grab the course home messages fragment to render any relevant django messages
course_home_message_fragment = CourseHomeMessageFragmentView().render_to_fragment(
request, course_id=course_id, user_access=user_access, **kwargs
)
# Get info for upgrade messaging
upgrade_price = None
upgrade_url = None
has_discount = False
# TODO Add switch to control deployment
if SHOW_UPGRADE_MSG_ON_COURSE_HOME.is_enabled(course_key) and can_show_verified_upgrade(
request.user,
enrollment,
course
):
upgrade_url = verified_upgrade_deadline_link(request.user, course_id=course_key)
upgrade_price, has_discount = format_strikeout_price(request.user, course_overview)
show_search = (
settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH') or
(settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH_FOR_COURSE_STAFF') and user_access['is_staff'])
)
# Render the course home fragment
context = {
'request': request,
'csrf': csrf(request)['csrf_token'],
'course': course,
'course_key': course_key,
'outline_fragment': outline_fragment,
'handouts_html': handouts_html,
'course_home_message_fragment': course_home_message_fragment,
'course_expiration_fragment': course_expiration_fragment,
'has_visited_course': has_visited_course,
'resume_course_url': resume_course_url,
'course_tools': course_tools,
'dates_fragment': dates_fragment,
'username': request.user.username,
'goal_api_url': goal_api_url,
'has_goal_permission': has_goal_permission,
'goal_options': goal_options,
'current_goal': current_goal,
'update_message_fragment': update_message_fragment,
'course_sock_fragment': course_sock_fragment,
'disable_courseware_js': True,
'uses_bootstrap': True,
'upgrade_price': upgrade_price,
'upgrade_url': upgrade_url,
'has_discount': has_discount,
'show_search': show_search,
}
html = render_to_string('course_experience/course-home-fragment.html', context)
return Fragment(html)
@ensure_valid_course_key
def outline_tab(request, course_id):
"""Simply redirects to the MFE outline tab, as this legacy view for the course home/outline no longer exists."""
return redirect(get_learning_mfe_home_url(course_key=course_id, url_fragment='home', params=request.GET))

View File

@@ -1,232 +0,0 @@
"""
View logic for handling course messages.
"""
from datetime import datetime
from urllib.parse import quote_plus
from babel.dates import format_date, format_timedelta
from django.conf import settings
from django.contrib import auth
from django.template.loader import render_to_string
from django.utils.translation import get_language, to_locale
from django.utils.translation import gettext as _
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from web_fragments.fragment import Fragment
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.courseware.courses import get_course_date_blocks, 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,
valid_course_goals_ordered
)
from lms.djangoapps.course_goals.models import GOAL_KEY_CHOICES
from lms.djangoapps.courseware.access_utils import check_public_access
from lms.djangoapps.courseware.toggles import course_is_invitation_only
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.course_experience import CourseHomeMessages
from common.djangoapps.student.models import CourseEnrollment
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC # lint-amnesty, pylint: disable=wrong-import-order
class CourseHomeMessageFragmentView(EdxFragmentView):
"""
A fragment that displays a course message with an alert and call
to action for three types of users:
1) Not logged in users are given a link to sign in or register.
2) Unenrolled users are given a link to enroll.
3) Enrolled users who get to the page before the course start date
are given the option to add the start date to their calendar.
This fragment requires a user_access map as follows:
user_access = {
'is_anonymous': True if the user is logged in, False otherwise
'is_enrolled': True if the user is enrolled in the course, False otherwise
'is_staff': True if the user is a staff member of the course, False otherwise
}
"""
def render_to_fragment(self, request, course_id, user_access, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
"""
Renders a course message fragment for the specified course.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key)
# Get time until the start date, if already started, or no start date, value will be zero or negative
now = datetime.now(UTC)
already_started = course.start and now > course.start
days_until_start_string = "started" if already_started else format_timedelta(
course.start - now, locale=to_locale(get_language())
)
course_start_data = {
'course_start_date': format_date(course.start, locale=to_locale(get_language())),
'already_started': already_started,
'days_until_start_string': days_until_start_string
}
# Register the course home messages to be loaded on the page
_register_course_home_messages(request, course, user_access, course_start_data)
# Register course date alerts
for course_date_block in get_course_date_blocks(course, request.user, request):
course_date_block.register_alerts(request, course)
# Register a course goal message, if appropriate
# Only show the set course goal message for enrolled, unverified
# users that have not yet set a goal in a course that allows for
# verified statuses.
user_goal = get_course_goal(auth.get_user(request), course_key)
is_already_verified = CourseEnrollment.is_enrolled_as_verified(request.user, course_key)
if has_course_goal_permission(request, course_id, user_access) and not is_already_verified and not user_goal:
_register_course_goal_message(request, course)
# Grab the relevant messages
course_home_messages = list(CourseHomeMessages.user_messages(request))
# Pass in the url used to set a course goal
goal_api_url = get_goal_api_url(request)
# Grab the logo
image_src = 'course_experience/images/home_message_author.png'
context = {
'course_home_messages': course_home_messages,
'goal_api_url': goal_api_url,
'image_src': image_src,
'course_id': course_id,
'username': request.user.username,
}
html = render_to_string('course_experience/course-messages-fragment.html', context)
return Fragment(html)
def _register_course_home_messages(request, course, user_access, course_start_data): # lint-amnesty, pylint: disable=unused-argument
"""
Register messages to be shown in the course home content page.
"""
allow_anonymous = check_public_access(course, [COURSE_VISIBILITY_PUBLIC])
if user_access['is_anonymous'] and not allow_anonymous:
sign_in_or_register_text = (_('{sign_in_link} or {register_link} and then enroll in this course.')
if not CourseMode.is_masters_only(course.id)
else _('{sign_in_link} or {register_link}.'))
CourseHomeMessages.register_info_message(
request,
Text(sign_in_or_register_text).format(
sign_in_link=HTML('<a href="/login?next={current_url}">{sign_in_label}</a>').format(
sign_in_label=_('Sign in'),
current_url=quote_plus(request.path),
),
register_link=HTML('<a href="/register?next={current_url}">{register_label}</a>').format(
register_label=_('register'),
current_url=quote_plus(request.path),
)
),
title=Text(_('You must be enrolled in the course to see course content.'))
)
if not user_access['is_anonymous'] and not user_access['is_staff'] and \
not user_access['is_enrolled']:
title = Text(_('Welcome to {course_display_name}')).format(
course_display_name=course.display_name
)
if CourseMode.is_masters_only(course.id):
# if a course is a Master's only course, we will not offer user ability to self-enroll
CourseHomeMessages.register_info_message(
request,
Text(_(
'You must be enrolled in the course to see course content. '
'Please contact your degree administrator or {platform_name} Support if you have questions.'
)).format(platform_name=settings.PLATFORM_NAME),
title=title
)
elif not course_is_invitation_only(course):
CourseHomeMessages.register_info_message(
request,
Text(_(
'{open_enroll_link}Enroll now{close_enroll_link} to access the full course.'
)).format(
open_enroll_link=HTML('<button class="enroll-btn btn-link">'),
close_enroll_link=HTML('</button>')
),
title=title
)
else:
CourseHomeMessages.register_info_message(
request,
Text(_('You must be enrolled in the course to see course content.')),
)
def _register_course_goal_message(request, course):
"""
Register a message to let a learner specify a course goal.
"""
course_goal_options = get_course_goal_options()
goal_choices_html = Text(_(
'To start, set a course goal by selecting the option below that best describes '
'your learning plan. {goal_options_container}'
)).format(
goal_options_container=HTML('<div class="row goal-options-container">')
)
# 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(
'<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option dismissible" '
'data-choice="{goal_key}">'
).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('</div>'),
)
# 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(
'<button tabindex="0" aria-label="{aria_label_choice}" class="goal-option btn-outline-primary" '
'data-choice="{goal_key}">'
).format(
goal_key=goal_key,
aria_label_choice=Text(_("Set goal to: {goal_text}")).format(
goal_text=Text(_(goal_text)) # lint-amnesty, pylint: disable=translation-of-non-string
)
),
goal_text=goal_text,
closing_tag=HTML('</button>')
)
CourseHomeMessages.register_info_message(
request,
HTML('{goal_choices_html}{closing_tag}').format(
goal_choices_html=goal_choices_html,
closing_tag=HTML('</div>')
),
title=Text(_('Welcome to {course_display_name}')).format(
course_display_name=course.display_name
)
)

View File

@@ -1,165 +0,0 @@
"""
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])

View File

@@ -2,11 +2,9 @@
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
@@ -15,7 +13,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_name
from openedx.features.course_experience import default_course_url
from openedx.features.course_experience.course_updates import get_ordered_updates
@@ -48,8 +46,7 @@ class CourseUpdatesFragmentView(EdxFragmentView):
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
course_url_name = default_course_url_name(course.id)
course_url = reverse(course_url_name, kwargs={'course_id': str(course.id)})
course_url = default_course_url(course.id)
ordered_updates = get_ordered_updates(request, course)
plain_html_updates = ''

View File

@@ -1,50 +0,0 @@
"""
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)

View File

@@ -1,67 +0,0 @@
""" # 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()

View File

@@ -1,73 +0,0 @@
## 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">
<div class="course-view page-content-container" id="course-container">
<header class="page-header has-secondary">
<div class="page-header-main">
<nav aria-label="${_('Search Results')}" class="sr-is-focusable" tabindex="-1">
<div class="has-breadcrumbs">
<div class="breadcrumbs">
<span class="nav-item">
<a href="${course_url}">${course_home_page_title(course)}</a>
</span>
<span class="icon fa fa-angle-right" aria-hidden="true"></span>
<span class="nav-item">${_('Search Results')}</span>
</div>
</div>
</nav>
</div>
<div class="page-header-secondary">
<div class="page-header-search">
<form class="search-form input-group" role="search">
<input
class="field-input input-text search-field form-control"
type="search"
name="query"
id="search"
value="${query}"
placeholder="${_('Search the course')}"
/>
<label class="field-label sr-only" for="search" id="search-hint">${_('Search the course')}</label>
<div class="input-group-append input-group-btn">
<button class="btn btn-outline-primary search-button" type="submit">${_('Search')}</button>
</div>
</form>
</div>
</div>
</header>
<div class="page-content">
<main role="main" class="search-results" id="main" tabindex="-1">
</main>
</div>
</div>
</%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>

View File

@@ -1,15 +0,0 @@
"""
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',
),
]

View File

@@ -1,68 +0,0 @@
"""
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)

View File

@@ -14,13 +14,12 @@ 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, reverse
from django.urls import NoReverseMatch
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 (
@@ -616,39 +615,6 @@ 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):
"""

View File

@@ -5,12 +5,11 @@ 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
@@ -25,7 +24,6 @@ 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
@@ -471,29 +469,6 @@ 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.
@@ -505,7 +480,8 @@ def is_course_accessed(user, course_id):
Returns:
(bool): True if course has been accessed by the enterprise learner.
"""
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)
try:
get_key_to_last_completed_block(user, course_id)
return True
except UnavailableCompletionData:
return False