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 commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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')),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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't forget, you have 2 weeks left to upgrade to a Verified Certificate.'],
|
||||
['2017-07-04 10:00:00', 'Don't forget, you have 1 day left to upgrade to a Verified Certificate.'],
|
||||
['2017-07-05 08:00:00', 'Don't forget, you have 1 hour left to upgrade to a Verified Certificate.'],
|
||||
['2017-07-05 08:55:00', 'Don'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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3180,7 +3178,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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
14
lms/urls.py
14
lms/urls.py
@@ -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 += [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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': {},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1 +0,0 @@
|
||||
<button class="enroll-btn btn-link">Enroll Now</button>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> ${_("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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>")
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user