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

@@ -104,10 +104,8 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
# Check whether we were correctly redirected
if redirect:
if has_started:
self.assertRedirects(
response, reverse('openedx.course_experience.course_home', kwargs={'course_id': course.id}),
target_status_code=302, # for follow-on redirection to MFE (ideally we'd just be sent there first)
)
mfe_url = f'http://learning-mfe/course/{course.id}/home'
self.assertRedirects(response, mfe_url, fetch_redirect_response=False)
else:
self.assertRedirects(response, reverse('dashboard'))
else:

View File

@@ -40,6 +40,7 @@ from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_duration_limits.access import get_user_course_duration, get_user_course_expiration_date
from openedx.features.course_experience import course_home_url
from openedx.features.enterprise_support.api import enterprise_customer_for_request
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.util.db import outer_atomic
@@ -413,7 +414,7 @@ class ChooseModeView(View):
302 to the course if possible or the dashboard if not.
"""
if course.has_started() or user.is_staff:
return redirect(reverse('openedx.course_experience.course_home', kwargs={'course_id': course_key}))
return redirect(course_home_url(course_key))
else:
return redirect(reverse('dashboard'))

View File

@@ -1,57 +0,0 @@
/**
* Used to ellipsize a section of arbitrary HTML after a specified number of words.
*
* Note: this will modify the DOM structure of root in place.
* To keep the original around, you may want to save the result of cloneNode(true) before calling this method.
*
* Known bug: This method will ignore any special whitespace in the source and simply output single spaces.
* Which means that   will not be respected. This is not considered worth solving at time of writing.
*
* Returns how many words remain (or a negative number if the content got clamped)
*/
function clampHtmlByWords(root, wordsLeft) {
'use strict';
if (root.nodeName === 'SCRIPT' || root.nodeName === 'STYLE') {
return wordsLeft; // early exit and ignore
}
var remaining = wordsLeft;
var nodes = Array.from(root.childNodes ? root.childNodes : []);
var words, chopped;
// First, cut short any text in our node, as necessary
if (root.nodeName === '#text' && root.data) {
// split on words, ignoring any resulting empty strings
words = root.data.split(/\s+/).filter(Boolean);
if (remaining < 0) {
root.data = ''; // eslint-disable-line no-param-reassign
} else if (remaining > words.length) {
remaining -= words.length;
} else {
// OK, let's add an ellipsis and cut some of root.data
chopped = words.slice(0, remaining).join(' ') + '…';
// But be careful to get any preceding space too
if (root.data.match(/^\s/)) {
chopped = ' ' + chopped;
}
root.data = chopped; // eslint-disable-line no-param-reassign
remaining = -1;
}
}
// Now do the same for any child nodes
nodes.forEach(function(node) {
if (remaining < 0) {
root.removeChild(node);
} else {
remaining = clampHtmlByWords(node, remaining);
}
});
return remaining;
}
module.exports = {
clampHtmlByWords: clampHtmlByWords
};

View File

@@ -1,38 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { clampHtmlByWords } from './clamp-html';
let container;
const scriptTag = '<script src="/asset-v1:edX+testX+1T2021+type@asset+block/script.js">const ignore = "me here"; alert("BAD");</script>';
const styleTag = '<style>h1 {color: orange;}</style>';
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
container = null;
});
describe('ClampHtml', () => {
test.each([
['', 0, ''],
['a b', 0, '…'],
['a b', 1, 'a…'],
['a b c', 2, 'a b…'],
['a <i>aa ab</i> b', 2, 'a <i>aa…</i>'],
['a <i>aa ab</i> <em>ac</em>', 2, 'a <i>aa…</i>'],
['a <i>aa <em>aaa</em></i>', 2, 'a <i>aa…</i>'],
['a <i>aa <em>aaa</em> ab</i>', 3, 'a <i>aa <em>aaa…</em></i>'],
['a <i>aa ab</i> b c', 4, 'a <i>aa ab</i> b…'],
[scriptTag + 'a b c', 2, scriptTag + 'a b…'],
[styleTag + 'a b c', 2, styleTag + 'a b…'],
[scriptTag + styleTag + 'a b c', 2, scriptTag + styleTag + 'a b…'],
])('clamps by words: %s, %i', (input, wordsLeft, expected) => {
const div = ReactDOM.render(<div dangerouslySetInnerHTML={{ __html: input }} />, container);
clampHtmlByWords(div, wordsLeft);
expect(div.innerHTML).toEqual(expected);
});
});

View File

@@ -25,6 +25,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi
from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline
from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData
from openedx.core.lib.api.view_utils import LazySequence
from openedx.features.course_experience import course_home_url
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
@@ -285,8 +286,7 @@ def get_course_run_url(request, course_id):
Returns:
(string): the URL to the course run associated with course_id
"""
course_run_url = reverse('openedx.course_experience.course_home', args=[course_id])
return request.build_absolute_uri(course_run_url)
return request.build_absolute_uri(course_home_url(course_id))
def get_course_members(course_key):

View File

@@ -2,31 +2,9 @@
Course Goals Python API
"""
from opaque_keys.edx.keys import CourseKey
from rest_framework.reverse import reverse
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.course_goals.models import CourseGoal, GOAL_KEY_CHOICES
from openedx.features.course_experience import ENABLE_COURSE_GOALS
def add_course_goal_deprecated(user, course_id, goal_key):
"""
Add a new course goal for the provided user and course. If the goal
already exists, simply update and save the goal.
This method is for the deprecated version of course goals and will be removed as soon
as the newer number of days version of course goals is fully implemented.
Arguments:
user: The user that is setting the goal
course_id (string): The id for the course the goal refers to
goal_key (string): The goal key for the new goal.
"""
course_key = CourseKey.from_string(str(course_id))
CourseGoal.objects.update_or_create(
user=user, course_key=course_key, defaults={'goal_key': goal_key}
)
from lms.djangoapps.course_goals.models import CourseGoal
def add_course_goal(user, course_id, subscribed_to_reminders, days_per_week=None):
@@ -61,58 +39,3 @@ def get_course_goal(user, course_key):
return None
return CourseGoal.objects.filter(user=user, course_key=course_key).first()
def get_goal_api_url(request):
"""
Returns the endpoint for accessing REST API.
"""
return reverse('course_goals_api:v0:course_goal-list', request=request)
def has_course_goal_permission(request, course_id, user_access):
"""
Returns whether the user can access the course goal functionality.
Only authenticated users that are enrolled in a verifiable course
can use this feature.
"""
course_key = CourseKey.from_string(course_id)
has_verified_mode = CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(course_key))
return user_access['is_enrolled'] and has_verified_mode and ENABLE_COURSE_GOALS.is_enabled(course_key)
def get_course_goal_options():
"""
Returns the valid options for goal keys, mapped to their translated
strings, as defined by theCourseGoal model.
"""
return dict(GOAL_KEY_CHOICES)
def get_course_goal_text(goal_key):
"""
Returns the translated string for the given goal key
"""
goal_options = get_course_goal_options()
return goal_options[goal_key]
def valid_course_goals_ordered(include_unsure=False):
"""
Returns a list of the valid options for goal keys ordered by the level of commitment.
Each option is represented as a tuple, with (goal_key, goal_string).
This list does not return the unsure option by default since it does not have a relevant commitment level.
"""
goal_options = get_course_goal_options()
ordered_goal_options = []
ordered_goal_options.append((GOAL_KEY_CHOICES.certify, goal_options[GOAL_KEY_CHOICES.certify]))
ordered_goal_options.append((GOAL_KEY_CHOICES.complete, goal_options[GOAL_KEY_CHOICES.complete]))
ordered_goal_options.append((GOAL_KEY_CHOICES.explore, goal_options[GOAL_KEY_CHOICES.explore]))
if include_unsure:
ordered_goal_options.append((GOAL_KEY_CHOICES.unsure, goal_options[GOAL_KEY_CHOICES.unsure]))
return ordered_goal_options

View File

@@ -17,7 +17,6 @@ def emit_course_goal_event(sender, instance, **kwargs): # lint-amnesty, pylint:
name = 'edx.course.goal.added' if kwargs.get('created', False) else 'edx.course.goal.updated'
properties = {
'courserun_key': str(instance.course_key),
'goal_key': instance.goal_key,
'days_per_week': instance.days_per_week,
'subscribed_to_reminders': instance.subscribed_to_reminders,
}

View File

@@ -8,7 +8,6 @@ from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from edx_django_utils.cache import TieredCache
from model_utils import Choices
from model_utils.models import TimeStampedModel
@@ -20,12 +19,11 @@ from lms.djangoapps.courseware.context_processor import get_user_timezone_or_las
from openedx.core.lib.mobile_utils import is_request_from_mobile_app
from openedx.features.course_experience import ENABLE_COURSE_GOALS
# Each goal is represented by a goal key and a string description.
GOAL_KEY_CHOICES = Choices(
('certify', _('Earn a certificate')),
('complete', _('Complete the course')),
('explore', _('Explore the course')),
('unsure', _('Not sure yet')),
_GOAL_KEY_CHOICES = Choices(
('certify', 'Earn a certificate'),
('complete', 'Complete the course'),
('explore', 'Explore the course'),
('unsure', 'Not sure yet'),
)
User = get_user_model()
@@ -57,7 +55,9 @@ class CourseGoal(models.Model):
unsubscribe_token = models.UUIDField(null=True, blank=True, unique=True, editable=False, default=uuid.uuid4,
help_text='Used to validate unsubscribe requests without requiring a login')
goal_key = models.CharField(max_length=100, choices=GOAL_KEY_CHOICES, default=GOAL_KEY_CHOICES.unsure)
# Deprecated and unused - replaced by days_per_week and its subscription-based approach to goals
goal_key = models.CharField(max_length=100, choices=_GOAL_KEY_CHOICES, default=_GOAL_KEY_CHOICES.unsure)
history = HistoricalRecords()
def __str__(self):

View File

@@ -1,114 +0,0 @@
"""
Unit tests for course_goals.views methods.
"""
from unittest import mock
from django.test.utils import override_settings
from django.urls import reverse
from rest_framework.test import APIClient
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.course_goals.models import CourseGoal
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
EVENT_NAME_ADDED = 'edx.course.goal.added'
EVENT_NAME_UPDATED = 'edx.course.goal.updated'
class TestCourseGoalsAPI(SharedModuleStoreTestCase):
"""
Testing the Course Goals API.
"""
def setUp(self):
# Create a course with a verified track
super().setUp()
self.course = CourseFactory.create(emit_signals=True)
self.user = UserFactory.create(username='john', email='lennon@thebeatles.com', password='password')
CourseEnrollment.enroll(self.user, self.course.id)
self.client = APIClient(enforce_csrf_checks=True)
self.client.login(username=self.user.username, password=self.user.password)
self.client.force_authenticate(user=self.user)
self.apiUrl = reverse('course_goals_api:v0:course_goal-list')
@mock.patch('lms.djangoapps.course_goals.handlers.segment.track')
@override_settings(LMS_SEGMENT_KEY="foobar")
def test_add_valid_goal(self, segment_call):
""" Ensures a correctly formatted post succeeds."""
response = self.post_course_goal(valid=True, goal_key='certify')
segment_call.assert_called_once_with(self.user.id, EVENT_NAME_ADDED, {
'courserun_key': str(self.course.id),
'goal_key': 'certify',
'days_per_week': 0,
'subscribed_to_reminders': False,
})
assert response.status_code == 201
current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id)
assert len(current_goals) == 1
assert current_goals[0].goal_key == 'certify'
def test_add_invalid_goal(self):
""" Ensures an incorrectly formatted post does not succeed. """
response = self.post_course_goal(valid=False)
assert response.status_code == 400
assert len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)) == 0
def test_add_without_goal_key(self):
""" Ensures if no goal key provided, post does not succeed. """
response = self.post_course_goal(goal_key=None)
assert len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)) == 0
self.assertContains(
response=response,
text='Please provide a valid goal key from following options.',
status_code=400
)
@mock.patch('lms.djangoapps.course_goals.handlers.segment.track')
@override_settings(LMS_SEGMENT_KEY="foobar")
def test_update_goal(self, segment_call):
""" Ensures that repeated course goal post events do not create new instances of the goal. """
self.post_course_goal(valid=True, goal_key='explore')
self.post_course_goal(valid=True, goal_key='certify')
self.post_course_goal(valid=True, goal_key='unsure')
segment_call.assert_any_call(self.user.id, EVENT_NAME_ADDED, {
'courserun_key': str(self.course.id), 'goal_key': 'explore',
'days_per_week': 0,
'subscribed_to_reminders': False,
})
segment_call.assert_any_call(self.user.id, EVENT_NAME_UPDATED, {
'courserun_key': str(self.course.id), 'goal_key': 'certify',
'days_per_week': 0,
'subscribed_to_reminders': False,
})
segment_call.assert_any_call(self.user.id, EVENT_NAME_UPDATED, {
'courserun_key': str(self.course.id), 'goal_key': 'unsure',
'days_per_week': 0,
'subscribed_to_reminders': False,
})
current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id)
assert len(current_goals) == 1
assert current_goals[0].goal_key == 'unsure'
def post_course_goal(self, valid=True, goal_key='certify'):
"""
Sends a post request to set a course goal and returns the response.
"""
goal_key = goal_key if valid else 'invalid'
post_data = {
'course_key': self.course.id,
'user': self.user.username,
}
if goal_key:
post_data['goal_key'] = goal_key
response = self.client.post(self.apiUrl, post_data)
return response

View File

@@ -1,16 +0,0 @@
"""
Course Goals URLs
"""
from django.urls import include, path
from rest_framework import routers
from .views import CourseGoalViewSet
router = routers.DefaultRouter()
router.register(r'course_goals', CourseGoalViewSet, basename='course_goal')
urlpatterns = [
path('v0/', include((router.urls, "api"), namespace='v0')),
]

View File

@@ -1,104 +0,0 @@
"""
Course Goals Views - includes REST API
"""
from django.contrib.auth import get_user_model
from django.http import JsonResponse
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions, serializers, status, viewsets
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from lms.djangoapps.course_goals.api import get_course_goal_options
from lms.djangoapps.course_goals.models import GOAL_KEY_CHOICES, CourseGoal
from openedx.core.lib.api.permissions import IsStaffOrOwner
User = get_user_model()
class CourseGoalSerializer(serializers.ModelSerializer):
"""
Serializes CourseGoal models.
"""
user = serializers.SlugRelatedField(slug_field='username', queryset=User.objects.all())
class Meta:
model = CourseGoal
fields = ('user', 'course_key', 'goal_key')
class CourseGoalViewSet(viewsets.ModelViewSet):
"""
API calls to create and update a course goal.
Validates incoming data to ensure that course_key maps to an actual
course and that the goal_key is a valid option.
**Use Case**
* Create a new goal for a user.
* Update an existing goal for a user
**Example Requests**
POST /api/course_goals/v0/course_goals/
Request data: {"course_key": <course-key>, "goal_key": "<goal-key>", "user": "<username>"}
Returns Http400 response if the course_key does not map to a known
course or if the goal_key does not map to a valid goal key.
"""
authentication_classes = (JwtAuthentication, SessionAuthentication,)
permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,)
queryset = CourseGoal.objects.all()
serializer_class = CourseGoalSerializer
# Another version of this endpoint exists in ../course_home_api/outline/views.py
# This version is used by the legacy frontend and is deprecated
def create(self, post_data): # lint-amnesty, pylint: disable=arguments-differ
""" Create a new goal if one does not exist, otherwise update the existing goal. """
# Ensure goal_key is valid
goal_options = get_course_goal_options()
goal_key = post_data.data.get('goal_key')
if not goal_key:
return Response(
'Please provide a valid goal key from following options. (options= {goal_options}).'.format(
goal_options=goal_options,
),
status=status.HTTP_400_BAD_REQUEST,
)
elif goal_key not in goal_options:
return Response(
'Provided goal key, {goal_key}, is not a valid goal key (options= {goal_options}).'.format(
goal_key=goal_key,
goal_options=goal_options,
),
status=status.HTTP_400_BAD_REQUEST,
)
# Ensure course key is valid
course_key = CourseKey.from_string(post_data.data['course_key'])
if not course_key:
return Response(
'Provided course_key ({course_key}) does not map to a course.'.format(
course_key=course_key
),
status=status.HTTP_400_BAD_REQUEST,
)
user = post_data.user
goal = CourseGoal.objects.filter(user=user.id, course_key=course_key).first()
if goal:
goal.goal_key = goal_key
goal.save(update_fields=['goal_key'])
else:
CourseGoal.objects.create(
user=user,
course_key=course_key,
goal_key=goal_key,
)
data = {
'goal_key': str(goal_key),
'goal_text': str(goal_options[goal_key]),
'is_unsure': goal_key == GOAL_KEY_CHOICES.unsure,
}
return JsonResponse(data, content_type="application/json", status=(200 if goal else 201)) # lint-amnesty, pylint: disable=redundant-content-type-for-json-response

View File

@@ -72,7 +72,6 @@ class TestCourseGoalsAPI(SharedModuleStoreTestCase):
'courserun_key': str(self.course.id),
'days_per_week': 1,
'subscribed_to_reminders': True,
'goal_key': 'unsure',
})
current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id)
@@ -89,7 +88,6 @@ class TestCourseGoalsAPI(SharedModuleStoreTestCase):
'courserun_key': str(self.course.id),
'days_per_week': 1,
'subscribed_to_reminders': True,
'goal_key': 'unsure',
})
self.save_course_goal(3, True)
@@ -97,7 +95,6 @@ class TestCourseGoalsAPI(SharedModuleStoreTestCase):
'courserun_key': str(self.course.id),
'days_per_week': 3,
'subscribed_to_reminders': True,
'goal_key': 'unsure',
})
self.save_course_goal(5, False)
@@ -105,7 +102,6 @@ class TestCourseGoalsAPI(SharedModuleStoreTestCase):
'courserun_key': str(self.course.id),
'days_per_week': 5,
'subscribed_to_reminders': False,
'goal_key': 'unsure',
})
current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id)

View File

@@ -19,7 +19,6 @@ from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests
from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from openedx.core.djangoapps.content.learning_sequences.api import replace_course_outline
from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData, CourseVisibility
from openedx.core.djangoapps.course_date_signals.utils import MIN_DURATION
@@ -148,13 +147,6 @@ class OutlineTabTestViews(BaseCourseHomeTests):
response = self.client.get(url)
assert response.status_code == 404
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED)
def test_legacy_view_enabled(self, enrollment_mode):
CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode)
response = self.client.get(self.url)
assert response.status_code == 404
@ddt.data(True, False)
def test_welcome_message(self, welcome_message_is_dismissed):
CourseEnrollment.enroll(self.user, self.course.id)

View File

@@ -6,7 +6,6 @@ from datetime import datetime, timezone
from completion.exceptions import UnavailableCompletionData # lint-amnesty, pylint: disable=wrong-import-order
from completion.utilities import get_key_to_last_completed_block # lint-amnesty, pylint: disable=wrong-import-order
from django.conf import settings # lint-amnesty, pylint: disable=wrong-import-order
from django.http.response import Http404 # lint-amnesty, pylint: disable=wrong-import-order
from django.shortcuts import get_object_or_404 # lint-amnesty, pylint: disable=wrong-import-order
from django.urls import reverse # lint-amnesty, pylint: disable=wrong-import-order
from django.utils.translation import gettext as _ # lint-amnesty, pylint: disable=wrong-import-order
@@ -29,9 +28,6 @@ from lms.djangoapps.course_goals.api import (
)
from lms.djangoapps.course_goals.models import CourseGoal
from lms.djangoapps.course_home_api.outline.serializers import OutlineTabSerializer
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.context_processor import user_timezone_locale_prefs
from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section, get_course_with_access
@@ -54,7 +50,6 @@ from openedx.features.course_experience.url_helpers import get_learning_mfe_home
from openedx.features.course_experience.utils import get_course_outline_block_tree, get_start_block
from openedx.features.discounts.utils import generate_offer_data
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
class UnableToDismissWelcomeMessage(APIException):
@@ -166,10 +161,6 @@ class OutlineTabView(RetrieveAPIView):
def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements
course_key_string = kwargs.get('course_key_string')
course_key = CourseKey.from_string(course_key_string)
course_usage_key = modulestore().make_course_usage_key(course_key) # pylint: disable=unused-variable
if course_home_legacy_is_active(course_key):
raise Http404
# Enable NR tracing for this view based on course
monitoring_utils.set_custom_attribute('course_id', course_key_string)
@@ -384,7 +375,6 @@ def dismiss_welcome_message(request): # pylint: disable=missing-function-docstr
@permission_classes((IsAuthenticated,))
def save_course_goal(request): # pylint: disable=missing-function-docstring
course_id = request.data.get('course_id')
goal_key = request.data.get('goal_key')
days_per_week = request.data.get('days_per_week')
subscribed_to_reminders = request.data.get('subscribed_to_reminders')

View File

@@ -11,29 +11,12 @@ WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='course_home')
COURSE_HOME_MICROFRONTEND_PROGRESS_TAB = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'course_home_mfe_progress_tab', # lint-amnesty, pylint: disable=toggle-missing-annotation
__name__)
# .. toggle_name: course_home.course_home_use_legacy_frontend
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: This flag enables the use of the legacy view of course home as the default course frontend.
# .. Learning microfrontend (frontend-app-learning) is now an opt-out view, where if this flag is
# .. enabled the default changes from the learning microfrontend to legacy.
# .. toggle_warnings: None
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-06-11
# .. toggle_target_removal_date: 2022-05-15
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-797
COURSE_HOME_USE_LEGACY_FRONTEND = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'course_home_use_legacy_frontend', __name__)
def course_home_legacy_is_active(course_key):
return COURSE_HOME_USE_LEGACY_FRONTEND.is_enabled(course_key) or course_key.deprecated
def course_home_mfe_progress_tab_is_active(course_key):
# Avoiding a circular dependency
from .models import DisableProgressPageStackedConfig
return (
(not course_home_legacy_is_active(course_key)) and
not course_key.deprecated and
COURSE_HOME_MICROFRONTEND_PROGRESS_TAB.is_enabled(course_key) and
not DisableProgressPageStackedConfig.current(course_key=course_key).disabled
)

View File

@@ -10,8 +10,6 @@ import datetime
import crum
from babel.dates import format_timedelta
from django.conf import settings
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import get_language, to_locale
from django.utils.translation import gettext as _
@@ -19,14 +17,14 @@ from django.utils.translation import gettext_lazy
from lazy import lazy
from pytz import utc
from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.certificates.api import get_active_web_certificate, can_show_certificate_available_date_field
from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link, can_show_verified_upgrade
from lms.djangoapps.verify_student.models import VerificationDeadline
from lms.djangoapps.verify_student.services import IDVerificationService
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.djangolib.markup import HTML
from openedx.features.course_duration_limits.access import get_user_course_expiration_date
from openedx.features.course_experience import RELATIVE_DATES_FLAG, UPGRADE_DEADLINE_MESSAGE, CourseHomeMessages
from openedx.features.course_experience import RELATIVE_DATES_FLAG
from common.djangoapps.student.models import CourseEnrollment
from .context_processor import user_timezone_locale_prefs
@@ -79,12 +77,6 @@ class DateSummary:
"""Extra detail to display as a tooltip."""
return None
def register_alerts(self, request, course):
"""
Registers any relevant course alerts given the current request.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
@property
def date(self):
"""This summary's date."""
@@ -280,35 +272,6 @@ class CourseStartDate(DateSummary):
return gettext_lazy('Enrollment Date')
return gettext_lazy('Course starts')
def register_alerts(self, request, course):
"""
Registers an alert if the course has not started yet.
"""
is_enrolled = CourseEnrollment.get_enrollment(request.user, course.id)
if not course.start or not is_enrolled:
return
days_until_start = (course.start - self.current_time).days
if course.start > self.current_time:
if days_until_start > 0:
CourseHomeMessages.register_info_message(
request,
Text(_(
"Don't forget to add a calendar reminder!"
)),
title=Text(_("Course starts in {time_remaining_string} on {course_start_date}.")).format(
time_remaining_string=self.time_remaining_string,
course_start_date=self.long_date_html,
)
)
else:
CourseHomeMessages.register_info_message(
request,
Text(_("Course starts in {time_remaining_string} at {course_start_time}.")).format(
time_remaining_string=self.time_remaining_string,
course_start_time=self.short_time_html,
)
)
class CourseEndDate(DateSummary):
"""
@@ -361,34 +324,6 @@ class CourseEndDate(DateSummary):
def date_type(self):
return 'course-end-date'
def register_alerts(self, request, course):
"""
Registers an alert if the end date is approaching.
"""
is_enrolled = CourseEnrollment.get_enrollment(request.user, course.id)
if not course.start or not course.end or self.current_time < course.start or not is_enrolled:
return
days_until_end = (course.end - self.current_time).days
if course.end > self.current_time and days_until_end <= settings.COURSE_MESSAGE_ALERT_DURATION_IN_DAYS:
if days_until_end > 0:
CourseHomeMessages.register_info_message(
request,
Text(self.description),
title=Text(_('This course is ending in {time_remaining_string} on {course_end_date}.')).format(
time_remaining_string=self.time_remaining_string,
course_end_date=self.long_date_html,
)
)
else:
CourseHomeMessages.register_info_message(
request,
Text(self.description),
title=Text(_('This course is ending in {time_remaining_string} at {course_end_time}.')).format(
time_remaining_string=self.time_remaining_string,
course_end_time=self.short_time_html,
)
)
class CourseAssignmentDate(DateSummary):
"""
@@ -512,31 +447,6 @@ class CertificateAvailableDate(DateSummary):
) if mode.slug != CourseMode.AUDIT
)
def register_alerts(self, request, course):
"""
Registers an alert close to the certificate delivery date.
"""
is_enrolled = CourseEnrollment.get_enrollment(request.user, course.id)
if not is_enrolled or not self.is_enabled or (course.end and course.end > self.current_time):
return
if self.date > self.current_time:
CourseHomeMessages.register_info_message(
request,
Text(_(
'If you have earned a certificate, you will be able to access it {time_remaining_string}'
' from now. You will also be able to view your certificates on your {learner_profile_link}.'
)).format(
time_remaining_string=self.time_remaining_string,
learner_profile_link=HTML(
'<a href="{learner_profile_url}">{learner_profile_name}</a>'
).format(
learner_profile_url=reverse('learner_profile', kwargs={'username': request.user.username}),
learner_profile_name=_('Learner Profile'),
),
),
title=Text(_('We are working on generating course certificates.'))
)
class VerifiedUpgradeDeadlineDate(DateSummary):
"""
@@ -608,44 +518,6 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
# according to their locale.
return _('by {date}')
def register_alerts(self, request, course):
"""
Registers an alert if the verification deadline is approaching.
"""
upgrade_price = get_cosmetic_verified_display_price(course)
if not UPGRADE_DEADLINE_MESSAGE.is_enabled(course.id) or not self.is_enabled or not upgrade_price:
return
days_left_to_upgrade = (self.date - self.current_time).days
if self.date > self.current_time and days_left_to_upgrade <= settings.COURSE_MESSAGE_ALERT_DURATION_IN_DAYS:
upgrade_message = _(
"Don't forget, you have {time_remaining_string} left to upgrade to a Verified Certificate."
).format(time_remaining_string=self.time_remaining_string)
if self._dynamic_deadline() is not None:
upgrade_message = _(
"Don't forget to upgrade to a verified certificate by {localized_date}."
).format(localized_date=date_format(self.date))
CourseHomeMessages.register_info_message(
request,
Text(_(
'In order to qualify for a certificate, you must meet all course grading '
'requirements, upgrade before the course deadline, and successfully verify '
'your identity on {platform_name} if you have not done so already.{button_panel}'
)).format(
platform_name=settings.PLATFORM_NAME,
button_panel=HTML(
'<div class="message-actions">'
'<a id="certificate_upsell" class="btn btn-upgrade"'
'data-creative="original_message" data-position="course_message"'
'href="{upgrade_url}">{upgrade_label}</a>'
'</div>'
).format(
upgrade_url=self.link,
upgrade_label=Text(_('Upgrade ({upgrade_price})')).format(upgrade_price=upgrade_price),
)
),
title=Text(upgrade_message)
)
class VerificationDeadlineDate(DateSummary):
"""

View File

@@ -7,15 +7,15 @@ perform some LMS-specific tab display gymnastics for the Entrance Exams feature
from django.conf import settings
from django.utils.translation import gettext as _
from django.utils.translation import gettext_noop
from xmodule.tabs import CourseTab, CourseTabList, key_checker
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.entrance_exams import user_can_skip_entrance_exam
from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active, course_home_mfe_progress_tab_is_active
from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active
from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, default_course_url_name
from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, default_course_url
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
from common.djangoapps.student.models import CourseEnrollment
from xmodule.tabs import CourseTab, CourseTabList, course_reverse_func_from_name_func, key_checker # lint-amnesty, pylint: disable=wrong-import-order
class EnrolledTab(CourseTab):
@@ -41,13 +41,8 @@ class CoursewareTab(EnrolledTab):
supports_preview_menu = True
def __init__(self, tab_dict):
def link_func(course, reverse_func):
if course_home_legacy_is_active(course.id):
reverse_name_func = lambda course: default_course_url_name(course.id)
url_func = course_reverse_func_from_name_func(reverse_name_func)
return url_func(course, reverse_func)
else:
return get_learning_mfe_home_url(course_key=course.id, url_fragment='home')
def link_func(course, _reverse_func):
return default_course_url(course.id)
tab_dict['link_func'] = link_func
super().__init__(tab_dict)

View File

@@ -9,7 +9,6 @@ from unittest import mock
from unittest.mock import patch
import ddt
import pytz
from ccx_keys.locator import CCXLocator
from django.conf import settings
from django.test.utils import override_settings
from django.urls import reverse
@@ -29,13 +28,11 @@ from xmodule.modulestore.tests.utils import TEST_DATA_DIR
from xmodule.modulestore.xml_importer import import_course_from_xml
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.ccx.tests.factories import CcxFactory
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, course_home_url
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
from openedx.features.course_experience.waffle import WAFFLE_NAMESPACE as COURSE_EXPERIENCE_WAFFLE_NAMESPACE
from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentAllowedFactory, UserFactory
from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory
from common.djangoapps.track.tests import EventTrackingTestCase
from common.djangoapps.util.milestones_helpers import get_prerequisite_courses_display, set_prerequisite_courses
@@ -47,7 +44,6 @@ SHIB_ERROR_STR = "The currently logged-in user account does not have permission
@ddt.ddt
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTrackingTestCase, MilestonesTestCaseMixin):
"""
Tests about xblock.
@@ -124,13 +120,7 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra
self.setup_user()
url = reverse('about_course', args=[str(self.course.id)])
resp = self.client.get(url)
# should be redirected
assert resp.status_code == 302
# follow this time, and check we're redirected to the course home page
resp = self.client.get(url, follow=True)
target_url = resp.redirect_chain[-1][0]
course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)])
assert target_url.endswith(course_home_url)
self.assertRedirects(resp, course_home_url(self.course.id), fetch_redirect_response=False)
@patch.dict(settings.FEATURES, {'ENABLE_COURSE_HOME_REDIRECT': False})
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
@@ -229,7 +219,6 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra
self.assertContains(resp, "Enroll Now")
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
"""
Tests for the course about page
@@ -273,7 +262,6 @@ class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
self.assertContains(resp, self.xml_data)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class AboutWithCappedEnrollmentsTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
"""
This test case will check the About page when a course has a capped enrollment
@@ -316,7 +304,6 @@ class AboutWithCappedEnrollmentsTestCase(LoginEnrollmentTestCase, SharedModuleSt
self.assertNotContains(resp, REG_STR)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class AboutWithInvitationOnly(SharedModuleStoreTestCase):
"""
This test case will check the About page when a course is invitation only.
@@ -356,7 +343,6 @@ class AboutWithInvitationOnly(SharedModuleStoreTestCase):
self.assertContains(resp, REG_STR)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class AboutWithClosedEnrollment(ModuleStoreTestCase):
"""
This test case will check the About page for a course that has enrollment start/end
@@ -393,7 +379,6 @@ class AboutWithClosedEnrollment(ModuleStoreTestCase):
@ddt.ddt
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class AboutSidebarHTMLTestCase(SharedModuleStoreTestCase):
"""
This test case will check the About page for the content in the HTML sidebar.
@@ -433,38 +418,3 @@ class AboutSidebarHTMLTestCase(SharedModuleStoreTestCase):
self.assertContains(resp, itemfactory_data)
else:
self.assertNotContains(resp, '<section class="about-sidebar-html">')
class CourseAboutTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test for unenrolled student tries to access ccx.
Note: Only CCX coach can enroll a student in CCX. In sum self-registration not allowed.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
super().setUp()
# Create ccx coach account
self.coach = coach = AdminFactory.create(password="test")
self.client.login(username=coach.username, password="test")
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
def test_redirect_to_dashboard_unenrolled_ccx(self):
"""
Assert that when unenrolled user tries to access CCX do not allow the user to self-register.
Redirect them to their student dashboard
"""
# create ccx
ccx = CcxFactory(course_id=self.course.id, coach=self.coach)
ccx_locator = CCXLocator.from_course_locator(self.course.id, str(ccx.id))
self.setup_user()
url = reverse('openedx.course_experience.course_home', args=[ccx_locator])
response = self.client.get(url)
expected = reverse('dashboard')
self.assertRedirects(response, expected, status_code=302, target_status_code=200)

View File

@@ -5,20 +5,19 @@ Python tests for the Survey workflows
from collections import OrderedDict
from copy import deepcopy
from urllib.parse import quote
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.test.utils import XssTestMixin
from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase
from lms.djangoapps.survey.models import SurveyAnswer, SurveyForm
from openedx.features.course_experience import course_home_url
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTestMixin):
"""
All tests for the views.py file
@@ -78,7 +77,7 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe
"""
Helper method to assert that all known redirect points do redirect as expected
"""
for view_name in ['courseware', 'openedx.course_experience.course_home', 'progress']:
for view_name in ['courseware', 'progress']:
resp = self.client.get(
reverse(
view_name,
@@ -95,7 +94,7 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe
Helper method to asswer that all known conditionally redirect points do
not redirect as expected
"""
for view_name in ['courseware', 'openedx.course_experience.course_home', 'progress']:
for view_name in ['courseware', 'progress']:
resp = self.client.get(
reverse(
view_name,
@@ -119,17 +118,20 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe
def test_anonymous_user_visiting_course_with_survey(self):
"""
Verifies that anonymous user going to the courseware home with an unanswered survey is not
redirected to survey and home page renders without server error.
Verifies that anonymous user going to the course with an unanswered survey is not
redirected to survey.
"""
self.logout()
resp = self.client.get(
reverse(
'openedx.course_experience.course_home',
'courseware',
kwargs={'course_id': str(self.course.id)}
)
)
assert resp.status_code == 200
self.assertRedirects(
resp,
f'/login?next=/courses/{quote(str(self.course.id))}/courseware'
)
def test_visiting_course_with_existing_answers(self):
"""
@@ -206,10 +208,10 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe
kwargs={'course_id': str(self.course_with_bogus_survey.id)}
)
)
course_home_path = 'openedx.course_experience.course_home'
self.assertRedirects(
resp,
reverse(course_home_path, kwargs={'course_id': str(self.course_with_bogus_survey.id)})
course_home_url(self.course_with_bogus_survey.id),
fetch_redirect_response=False,
)
def test_visiting_survey_with_no_course_survey(self):
@@ -223,10 +225,10 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe
kwargs={'course_id': str(self.course_without_survey.id)}
)
)
course_home_path = 'openedx.course_experience.course_home'
self.assertRedirects(
resp,
reverse(course_home_path, kwargs={'course_id': str(self.course_without_survey.id)})
course_home_url(self.course_without_survey.id),
fetch_redirect_response=False,
)
def test_survey_xss(self):

View File

@@ -9,7 +9,6 @@ import crum
import ddt
import waffle # lint-amnesty, pylint: disable=invalid-django-waffle-import
from django.conf import settings
from django.contrib.messages.middleware import MessageMiddleware
from django.test import RequestFactory
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
@@ -22,7 +21,6 @@ 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 lms.djangoapps.commerce.models import CommerceConfiguration
from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from lms.djangoapps.courseware.courses import get_course_date_blocks
from lms.djangoapps.courseware.date_summary import (
CertificateAvailableDate,
@@ -45,14 +43,8 @@ from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVer
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_experience import (
DISABLE_UNIFIED_COURSE_TAB_FLAG,
RELATIVE_DATES_FLAG,
UPGRADE_DEADLINE_MESSAGE,
CourseHomeMessages
)
from openedx.features.course_experience import RELATIVE_DATES_FLAG
from common.djangoapps.student.tests.factories import TEST_PASSWORD, CourseEnrollmentFactory, UserFactory
@@ -82,13 +74,6 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
response = self.client.get(url)
self.assertNotContains(response, 'date-summary', status_code=302)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
def test_course_home_logged_out(self):
course = create_course_run()
url = reverse('openedx.course_experience.course_home', args=(course.id,))
response = self.client.get(url)
assert 200 == response.status_code
# Tests for which blocks are enabled
def assert_block_types(self, course, user, expected_blocks):
"""Assert that the enabled block types for this course are as expected."""
@@ -424,53 +409,6 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
assert block.date == datetime.now(utc)
assert block.title == 'current_datetime'
@ddt.data(
'info',
'openedx.course_experience.course_home',
)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=False)
def test_todays_date_no_timezone(self, url_name):
with freeze_time('2015-01-02'):
course = create_course_run()
user = create_user()
self.client.login(username=user.username, password=TEST_PASSWORD)
html_elements = [
'<h3 class="hd hd-6 handouts-header">Upcoming Dates</h3>',
'<div class="date-summary',
'<p class="hd hd-6 date localized-datetime"',
'data-timezone="None"'
]
url = reverse(url_name, args=(course.id,))
response = self.client.get(url, follow=True)
for html in html_elements:
self.assertContains(response, html)
@ddt.data(
'info',
'openedx.course_experience.course_home',
)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=False)
def test_todays_date_timezone(self, url_name):
with freeze_time('2015-01-02'):
course = create_course_run()
user = create_user()
self.client.login(username=user.username, password=TEST_PASSWORD)
set_user_preference(user, 'time_zone', 'America/Los_Angeles')
url = reverse(url_name, args=(course.id,))
response = self.client.get(url, follow=True)
html_elements = [
'<h3 class="hd hd-6 handouts-header">Upcoming Dates</h3>',
'<div class="date-summary',
'<p class="hd hd-6 date localized-datetime"',
'data-timezone="America/Los_Angeles"'
]
for html in html_elements:
self.assertContains(response, html)
## Tests Course Start Date
def test_course_start_date(self):
course = create_course_run()
@@ -478,46 +416,6 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = CourseStartDate(course, user)
assert block.date == course.start
@ddt.data(
'info',
'openedx.course_experience.course_home',
)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=False)
def test_start_date_render(self, url_name):
with freeze_time('2015-01-02'):
course = create_course_run()
user = create_user()
self.client.login(username=user.username, password=TEST_PASSWORD)
url = reverse(url_name, args=(course.id,))
response = self.client.get(url, follow=True)
html_elements = [
'data-datetime="2015-01-03 00:00:00+00:00"'
]
for html in html_elements:
self.assertContains(response, html)
@ddt.data(
'info',
'openedx.course_experience.course_home',
)
@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True)
@override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=False)
def test_start_date_render_time_zone(self, url_name):
with freeze_time('2015-01-02'):
course = create_course_run()
user = create_user()
self.client.login(username=user.username, password=TEST_PASSWORD)
set_user_preference(user, 'time_zone', 'America/Los_Angeles')
url = reverse(url_name, args=(course.id,))
response = self.client.get(url, follow=True)
html_elements = [
'data-datetime="2015-01-03 00:00:00+00:00"',
'data-timezone="America/Los_Angeles"'
]
for html in html_elements:
self.assertContains(response, html)
## Tests Course End Date Block
def test_course_end_date_for_certificate_eligible_mode(self):
course = create_course_run(days_till_start=-1)
@@ -723,160 +621,6 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = VerificationDeadlineDate(course, user)
assert block.relative_datestring == expected_date_string
@ddt.data(
'info',
'openedx.course_experience.course_home',
)
@override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=False)
@override_waffle_flag(RELATIVE_DATES_FLAG, active=True)
def test_dates_tab_link_render(self, url_name):
""" The dates tab link should only show for enrolled or staff users """
course = create_course_run()
html_elements = [
'class="dates-tab-link"',
'View all course dates</a>',
f'/course/{course.id}/dates',
]
url = reverse(url_name, args=(course.id,))
def assert_html_elements(assert_function, user):
self.client.login(username=user.username, password=TEST_PASSWORD)
response = self.client.get(url, follow=True)
if user.is_staff:
for html in html_elements:
assert_function(response, html)
else:
assert 404 == response.status_code
self.client.logout()
with freeze_time('2015-01-02'):
unenrolled_user = create_user()
assert_html_elements(self.assertNotContains, unenrolled_user)
staff_user = create_user()
staff_user.is_staff = True
staff_user.save()
assert_html_elements(self.assertContains, staff_user)
enrolled_user = create_user()
CourseEnrollmentFactory(course_id=course.id, user=enrolled_user, mode=CourseMode.VERIFIED)
assert_html_elements(self.assertContains, enrolled_user)
@ddt.ddt
class TestDateAlerts(SharedModuleStoreTestCase):
"""
Unit tests for date alerts.
"""
def setUp(self):
super().setUp()
with freeze_time('2017-07-01 09:00:00'):
self.course = create_course_run(days_till_start=0)
self.course.certificate_available_date = self.course.start + timedelta(days=21)
enable_course_certificates(self.course)
self.enrollment = CourseEnrollmentFactory(course_id=self.course.id, mode=CourseMode.AUDIT)
self.request = RequestFactory().request()
self.request.session = {}
self.request.user = self.enrollment.user
MessageMiddleware().process_request(self.request)
@ddt.data(
['2017-01-01 09:00:00', 'in 6 months on <span class="date localized-datetime" data-format="shortDate"'],
['2017-06-17 09:00:00', 'in 2 weeks on <span class="date localized-datetime" data-format="shortDate"'],
['2017-06-30 10:00:00', 'in 1 day at <span class="date localized-datetime" data-format="shortTime"'],
['2017-07-01 08:00:00', 'in 1 hour at <span class="date localized-datetime" data-format="shortTime"'],
['2017-07-01 08:55:00', 'in 5 minutes at <span class="date localized-datetime" data-format="shortTime"'],
['2017-07-01 09:00:00', None],
['2017-08-01 09:00:00', None],
)
@ddt.unpack
def test_start_date_alert(self, current_time, expected_message_html):
"""
Verify that course start date alerts are registered.
"""
with freeze_time(current_time):
block = CourseStartDate(self.course, self.request.user)
block.register_alerts(self.request, self.course)
messages = list(CourseHomeMessages.user_messages(self.request))
if expected_message_html:
assert len(messages) == 1
assert expected_message_html in messages[0].message_html
else:
assert len(messages) == 0
@ddt.data(
['2017-06-30 09:00:00', None],
['2017-07-01 09:00:00', 'in 2 weeks on <span class="date localized-datetime" data-format="shortDate"'],
['2017-07-14 10:00:00', 'in 1 day at <span class="date localized-datetime" data-format="shortTime"'],
['2017-07-15 08:00:00', 'in 1 hour at <span class="date localized-datetime" data-format="shortTime"'],
['2017-07-15 08:55:00', 'in 5 minutes at <span class="date localized-datetime" data-format="shortTime"'],
['2017-07-15 09:00:00', None],
['2017-08-15 09:00:00', None],
)
@ddt.unpack
def test_end_date_alert(self, current_time, expected_message_html):
"""
Verify that course end date alerts are registered.
"""
with freeze_time(current_time):
block = CourseEndDate(self.course, self.request.user)
block.register_alerts(self.request, self.course)
messages = list(CourseHomeMessages.user_messages(self.request))
if expected_message_html:
assert len(messages) == 1
assert expected_message_html in messages[0].message_html
else:
assert len(messages) == 0
@ddt.data(
['2017-06-20 09:00:00', None],
['2017-06-21 09:00:00', 'Don&#39;t forget, you have 2 weeks left to upgrade to a Verified Certificate.'],
['2017-07-04 10:00:00', 'Don&#39;t forget, you have 1 day left to upgrade to a Verified Certificate.'],
['2017-07-05 08:00:00', 'Don&#39;t forget, you have 1 hour left to upgrade to a Verified Certificate.'],
['2017-07-05 08:55:00', 'Don&#39;t forget, you have 5 minutes left to upgrade to a Verified Certificate.'],
['2017-07-05 09:00:00', None],
['2017-08-05 09:00:00', None],
)
@ddt.unpack
@override_waffle_flag(UPGRADE_DEADLINE_MESSAGE, active=True)
def test_verified_upgrade_deadline_alert(self, current_time, expected_message_html):
"""
Verify the verified upgrade deadline alerts.
"""
with freeze_time(current_time):
block = VerifiedUpgradeDeadlineDate(self.course, self.request.user)
block.register_alerts(self.request, self.course)
messages = list(CourseHomeMessages.user_messages(self.request))
if expected_message_html:
assert len(messages) == 1
assert expected_message_html in messages[0].message_html
else:
assert len(messages) == 0
@ddt.data(
['2017-07-15 08:00:00', None],
['2017-07-15 09:00:00', 'If you have earned a certificate, you will be able to access it 1 week from now.'],
['2017-07-21 09:00:00', 'If you have earned a certificate, you will be able to access it 1 day from now.'],
['2017-07-22 08:00:00', 'If you have earned a certificate, you will be able to access it 1 hour from now.'],
['2017-07-22 09:00:00', None],
['2017-07-23 09:00:00', None],
)
@ddt.unpack
@waffle.testutils.override_switch('certificates.auto_certificate_generation', True)
def test_certificate_availability_alert(self, current_time, expected_message_html):
"""
Verify the verified upgrade deadline alerts.
"""
with freeze_time(current_time):
block = CertificateAvailableDate(self.course, self.request.user)
block.register_alerts(self.request, self.course)
messages = list(CourseHomeMessages.user_messages(self.request))
if expected_message_html:
assert len(messages) == 1
assert expected_message_html in messages[0].message_html
else:
assert len(messages) == 0
@ddt.ddt
class TestScheduleOverrides(SharedModuleStoreTestCase):

View File

@@ -126,7 +126,7 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
assert ('course-navigation' in response.content.decode('utf-8')) == accordion
self.assertTabInactive('progress', response)
self.assertTabActive('home', response)
self.assertTabActive('courseware', response)
response = self.client.get(reverse('courseware_section', kwargs={
'course_id': str(self.course.id),
@@ -135,7 +135,7 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
}))
self.assertTabActive('progress', response)
self.assertTabInactive('home', response)
self.assertTabInactive('courseware', response)
@override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=1)
def test_inactive_session_timeout(self):

View File

@@ -79,7 +79,6 @@ from lms.djangoapps.certificates.tests.factories import (
)
from lms.djangoapps.commerce.models import CommerceConfiguration
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND
from lms.djangoapps.courseware import access_utils
from lms.djangoapps.courseware.access_utils import check_course_open_for_learner
from lms.djangoapps.courseware.model_data import FieldDataCache, set_score
@@ -144,13 +143,6 @@ def _set_preview_mfe_flag(active: bool):
return override_waffle_flag(COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW, active=active)
def _set_course_home_mfe_flag(activate_mfe: bool):
"""
A decorator/contextmanager to force the courseware home MFE flag on or off.
"""
return override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=(not activate_mfe))
@ddt.ddt
class TestJumpTo(ModuleStoreTestCase):
"""
@@ -591,32 +583,17 @@ class ViewsTestCase(BaseViewsTestCase):
self.assertNotContains(response, 'Problem 1')
self.assertNotContains(response, 'Problem 2')
@ddt.data(False, True)
def test_mfe_link_from_about_page(self, activate_mfe):
def test_mfe_link_from_about_page(self):
"""
Verify course about page links to the MFE when enabled.
Verify course about page links to the MFE.
"""
with self.store.default_store(ModuleStoreEnum.Type.split):
course = CourseFactory.create()
CourseEnrollment.enroll(self.user, course.id)
assert self.client.login(username=self.user.username, password=TEST_PASSWORD)
legacy_url = reverse(
'openedx.course_experience.course_home',
kwargs={
'course_id': str(course.id),
}
)
mfe_url = get_learning_mfe_home_url(course_key=course.id, url_fragment='home')
with _set_course_home_mfe_flag(activate_mfe):
response = self.client.get(reverse('about_course', args=[str(course.id)]))
if activate_mfe:
self.assertContains(response, mfe_url)
self.assertNotContains(response, legacy_url)
else:
self.assertNotContains(response, mfe_url)
self.assertContains(response, legacy_url)
response = self.client.get(reverse('about_course', args=[str(course.id)]))
self.assertContains(response, get_learning_mfe_home_url(course_key=course.id, url_fragment='home'))
def _create_url_for_enroll_staff(self):
"""

View File

@@ -40,7 +40,7 @@ from openedx.core.djangolib.markup import HTML, Text
from openedx.features.course_experience import (
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
DISABLE_COURSE_OUTLINE_PAGE_FLAG,
default_course_url_name
default_course_url
)
from openedx.features.course_experience.views.course_sock import CourseSockFragmentView
from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url
@@ -417,8 +417,7 @@ class CoursewareIndex(View):
Also returns the table of contents for the courseware.
"""
course_url_name = default_course_url_name(self.course.id)
course_url = reverse(course_url_name, kwargs={'course_id': str(self.course.id)})
course_url = default_course_url(self.course.id)
show_search = (
settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH') or
(settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH_FOR_COURSE_STAFF') and self.is_staff)

View File

@@ -69,7 +69,7 @@ from lms.djangoapps.certificates import api as certs_api
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.course_goals.models import UserActivity
from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active, course_home_mfe_progress_tab_is_active
from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active
from lms.djangoapps.courseware.access import has_access, has_ccx_coach_role
from lms.djangoapps.courseware.access_utils import check_course_open_for_learner, check_public_access
from lms.djangoapps.courseware.courses import (
@@ -124,7 +124,7 @@ from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.mobile_utils import is_request_from_mobile_app
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, course_home_url_name
from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, course_home_url
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
from openedx.features.course_experience.url_helpers import (
ExperienceOption,
@@ -488,7 +488,7 @@ def course_info(request, course_id):
# If the unified course experience is enabled, redirect to the "Course" tab
if not DISABLE_UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key):
return redirect(reverse(course_home_url_name(course_key), args=[course_id]))
return redirect(course_home_url(course_key))
with modulestore().bulk_operations(course_key):
course = get_course_with_access(request.user, 'load', course_key)
@@ -922,7 +922,7 @@ def course_about(request, course_id):
# If user needs to be redirected to course home then redirect
if _course_home_redirect_enabled():
return redirect(reverse(course_home_url_name(course_key), args=[str(course_key)]))
return redirect(course_home_url(course_key))
with modulestore().bulk_operations(course_key):
permission = get_permission_for_course_about()
@@ -935,10 +935,7 @@ def course_about(request, course_id):
studio_url = get_studio_url(course, 'settings/details')
if request.user.has_perm(VIEW_COURSE_HOME, course):
if course_home_legacy_is_active(course.id):
course_target = reverse(course_home_url_name(course.id), args=[str(course.id)])
else:
course_target = get_learning_mfe_home_url(course_key=course.id, url_fragment='home')
course_target = course_home_url(course.id)
else:
course_target = reverse('about_course', args=[str(course.id)])
@@ -1489,7 +1486,7 @@ def course_survey(request, course_id):
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key, check_survey_complete=False)
redirect_url = reverse(course_home_url_name(course.id), args=[course_id])
redirect_url = course_home_url(course_key)
# if there is no Survey associated with this course,
# then redirect to the course instead
@@ -1629,9 +1626,6 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
Returns an HttpResponse with HTML content for the xBlock with the given usage_key.
The returned HTML is a chromeless rendering of the xBlock (excluding content of the containing courseware).
"""
from lms.urls import RESET_COURSE_DEADLINES_NAME
from openedx.features.course_experience.urls import COURSE_HOME_VIEW_NAME
usage_key = UsageKey.from_string(usage_key_string)
usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))
@@ -1738,12 +1732,11 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
'missed_deadlines': missed_deadlines,
'missed_gated_content': missed_gated_content,
'has_ended': course.has_ended(),
'web_app_course_url': reverse(COURSE_HOME_VIEW_NAME, args=[course.id]),
'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'),
'on_courseware_page': True,
'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course),
'is_learning_mfe': is_learning_mfe,
'is_mobile_app': is_mobile_app,
'reset_deadlines_url': reverse(RESET_COURSE_DEADLINES_NAME),
'render_course_wide_assets': True,
**optimization_flags,

View File

@@ -2098,7 +2098,7 @@ class ProgramCourseEnrollmentOverviewGetTests(
def test_course_run_url(self):
self.log_in()
course_run_url = f'http://testserver/courses/{str(self.course_id)}/course/'
course_run_url = f'http://learning-mfe/course/{str(self.course_id)}/home'
response_status_code, response_course_runs = self.get_status_and_course_runs()
assert status.HTTP_200_OK == response_status_code

View File

@@ -3,13 +3,12 @@ Serializers for use in the support app.
"""
import json
from django.urls import reverse
from rest_framework import serializers
from common.djangoapps.student.models import CourseEnrollment, ManualEnrollmentAudit
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from openedx.core.djangoapps.catalog.utils import get_programs_by_uuids
from openedx.features.course_experience import default_course_url_name
from openedx.features.course_experience import default_course_url
DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S'
# pylint: disable=abstract-method
@@ -47,8 +46,7 @@ class ProgramCourseEnrollmentSerializer(serializers.Serializer):
model = ProgramCourseEnrollment
def get_course_url(self, obj):
course_url_name = default_course_url_name(obj.course_key)
return reverse(course_url_name, kwargs={'course_id': obj.course_key})
return default_course_url(obj.course_key)
class ProgramEnrollmentSerializer(serializers.Serializer):

View File

@@ -1036,8 +1036,6 @@ SOFTWARE_SECURE_REQUEST_RETRY_DELAY = 60 * 60
SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS = 6
RETRY_CALENDAR_SYNC_EMAIL_MAX_ATTEMPTS = 5
# Deadline message configurations
COURSE_MESSAGE_ALERT_DURATION_IN_DAYS = 14
MARKETING_EMAILS_OPT_IN = False
@@ -3186,7 +3184,6 @@ INSTALLED_APPS = [
'openedx.features.calendar_sync',
'openedx.features.course_bookmarks',
'openedx.features.course_experience',
'openedx.features.course_search',
'openedx.features.enterprise_support.apps.EnterpriseSupportConfig',
'openedx.features.learner_profile',
'openedx.features.course_duration_limits',

View File

@@ -25,11 +25,6 @@ from openedx.features.course_experience import course_home_page_title, DISABLE_C
completion_aggregator_url = settings.COMPLETION_AGGREGATOR_URL if settings.FEATURES.get("SHOW_PROGRESS_BAR", False) else ""
%>
% if display_reset_dates_banner:
<script type="text/javascript">
$('.reset-deadlines-banner').css('display', 'flex');
</script>
% endif
<%def name="course_name()">
<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
</%def>

View File

@@ -12,12 +12,10 @@ from django.urls import reverse
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.helpers import enrollment_mode_display
from common.djangoapps.student.helpers import user_has_passing_grade_in_course
from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active
from lms.djangoapps.verify_student.services import IDVerificationService
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.course_experience import course_home_url_name
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
from openedx.features.course_experience import course_home_url
from common.djangoapps.student.helpers import (
VERIFY_STATUS_NEED_TO_VERIFY,
VERIFY_STATUS_SUBMITTED,
@@ -82,7 +80,7 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG
% endif
>
<article class="course${mode_class}" aria-labelledby="course-title-${enrollment.course_id}" id="course-card-${course_card_index}">
<% course_target = reverse(course_home_url_name(course_overview.id), args=[str(course_overview.id)]) if course_home_legacy_is_active(course_overview.id) else get_learning_mfe_home_url(course_key=course_overview.id, url_fragment="home") %>
<% course_target = course_home_url(course_overview.id) %>
<section class="details" aria-labelledby="details-heading-${enrollment.course_id}">
<h2 class="hd hd-2 sr" id="details-heading-${enrollment.course_id}">${_('Course details')}</h2>
<div class="wrapper-course-image" aria-hidden="true">

View File

@@ -1,108 +0,0 @@
## mako
<%page expression_filter="h"/>
<%!
from django.utils.translation import ugettext as _
from lms.djangoapps.courseware.date_summary import CourseAssignmentDate
from common.djangoapps.course_modes.models import CourseMode
%>
<%
additional_styling_class = 'on-mobile' if is_mobile_app else 'has-button'
%>
<%def name="reset_dates_banner()">
<div class="banner-cta ${additional_styling_class}">
<div class="banner-cta-text">
% if is_mobile_app:
${_('It looks like you missed some important deadlines based on our suggested schedule. ')}
${_('To keep yourself on track, you can update this schedule and shift the past due assignments into the future by visiting ')}
<a class="mobile-dates-link" href="${web_app_course_url}">edx.org</a>.
${_(" Don't worry—you won't lose any of the progress you've made when you shift your due dates.")}
% else:
<strong>${_('It looks like you missed some important deadlines based on our suggested schedule.')}</strong>
${_("To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don't worry—you won't lose any of the progress you've made when you shift your due dates.")}
% endif
</div>
% if not is_mobile_app:
<div class="banner-cta-button">
<form method="post" action="${reset_deadlines_url}">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
<input type="hidden" name="course_id" value="${course.id}">
<button class="btn btn-outline-primary">${_("Shift due dates")}</button>
</form>
</div>
% endif
</div>
</%def>
<%def name="upgrade_to_reset_banner()">
<div class="banner-cta ${additional_styling_class}">
<div class="banner-cta-text">
% if is_mobile_app:
<strong>${_('You are auditing this course,')}</strong>
${_(' which means that you are unable to participate in graded assignments.')}
${_(' It looks like you missed some important deadlines based on our suggested schedule. Graded assignments and schedule adjustment are available to Verified Track learners.')}
% else:
<strong>${_('You are auditing this course,')}</strong>
${_(' which means that you are unable to participate in graded assignments.')}
${_(' It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')}
% endif
</div>
% if not is_mobile_app:
<div class="banner-cta-button">
<a class="personalized_learner_schedules_button" href="${verified_upgrade_link}">
<button type="button">
${_('Upgrade to shift due dates')}
</button>
</a>
</div>
% endif
</div>
</%def>
<%def name="upgrade_to_complete_graded_banner()">
<div class="banner-cta ${additional_styling_class}">
<div class="banner-cta-text">
% if is_mobile_app:
<strong>${_('You are auditing this course,')}</strong>
${_(' which means that you are unable to participate in graded assignments.')}
${_('Graded assignments are available to Verified Track learners.')}
% else:
<strong>${_('You are auditing this course,')}</strong>
${_(' which means that you are unable to participate in graded assignments.')}
${_(' To complete graded assignments as part of this course, you can upgrade today.')}
% endif
</div>
% if not is_mobile_app:
<div class="banner-cta-button">
<a class="personalized_learner_schedules_button" href="${verified_upgrade_link}">
<button type="button">
${_('Upgrade now')}
</button>
</a>
</div>
% endif
</div>
</%def>
% if not has_ended:
% if on_dates_tab and not missed_deadlines:
%if getattr(course, 'self_paced', False):
<div class="banner-cta">
<div class="banner-cta-text">
<strong>${_("We've built a suggested schedule to help you stay on track.")}</strong>
${_("But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on our suggested dates, you'll be able to adjust them to keep yourself on track.")}
</div>
</div>
% endif
% if content_type_gating_enabled:
${upgrade_to_complete_graded_banner()}
% endif
% elif missed_deadlines:
% if missed_gated_content:
${upgrade_to_reset_banner()}
% else:
${reset_dates_banner()}
% endif
% endif
% endif

View File

@@ -667,14 +667,6 @@ urlpatterns += [
include('openedx.features.calendar_sync.urls'),
),
# Course search
re_path(
r'^courses/{}/search/'.format(
settings.COURSE_ID_PATTERN,
),
include('openedx.features.course_search.urls'),
),
# Learner profile
path(
'u/',
@@ -809,12 +801,6 @@ if configuration_helpers.get_value('ENABLE_BULK_ENROLLMENT_VIEW', settings.FEATU
path('api/bulk_enroll/v1/', include('lms.djangoapps.bulk_enroll.urls')),
]
# Course goals
urlpatterns += [
path('api/course_goals/', include(('lms.djangoapps.course_goals.urls', 'lms.djangoapps.course_goals'),
namespace='course_goals_api')),
]
# Embargo
if settings.FEATURES.get('EMBARGO'):
urlpatterns += [

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

View File

@@ -107,14 +107,8 @@ module.exports = Merge.smart({
CompletionOnViewService: './lms/static/completion/js/CompletionOnViewService.js',
// Features
CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',
CourseHome: './openedx/features/course_experience/static/course_experience/js/CourseHome.js',
CourseOutline: './openedx/features/course_experience/static/course_experience/js/CourseOutline.js',
CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js',
Currency: './openedx/features/course_experience/static/course_experience/js/currency.js',
Enrollment: './openedx/features/course_experience/static/course_experience/js/Enrollment.js',
LatestUpdate: './openedx/features/course_experience/static/course_experience/js/LatestUpdate.js',
WelcomeMessage: './openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js',
AnnouncementsView: './openedx/features/announcements/static/announcements/jsx/Announcements.jsx',
CookiePolicyBanner: './common/static/js/src/CookiePolicyBanner.jsx',