From a515c2e9921027fb8c932d98781adaae492a36aa Mon Sep 17 00:00:00 2001 From: Michael Terry Date: Thu, 14 Apr 2022 14:16:29 -0400 Subject: [PATCH] Revert "Drop legacy outline tab in favor of the MFE's version" --- .../course_modes/tests/test_views.py | 6 +- common/djangoapps/course_modes/views.py | 3 +- common/static/common/js/utils/clamp-html.js | 57 ++ .../common/js/utils/clamp-html.test.jsx | 38 + lms/djangoapps/course_api/api.py | 4 +- lms/djangoapps/course_goals/api.py | 81 +- lms/djangoapps/course_goals/handlers.py | 1 + lms/djangoapps/course_goals/models.py | 16 +- .../course_goals/tests/test_views.py | 114 +++ lms/djangoapps/course_goals/urls.py | 16 + lms/djangoapps/course_goals/views.py | 104 ++ .../outline/tests/test_goals.py | 4 + .../outline/tests/test_view.py | 8 + .../course_home_api/outline/views.py | 10 + lms/djangoapps/course_home_api/toggles.py | 19 +- lms/djangoapps/courseware/date_summary.py | 134 ++- lms/djangoapps/courseware/tabs.py | 15 +- lms/djangoapps/courseware/tests/test_about.py | 56 +- .../courseware/tests/test_course_survey.py | 28 +- .../courseware/tests/test_date_summary.py | 258 ++++- .../courseware/tests/test_navigation.py | 4 +- lms/djangoapps/courseware/tests/test_views.py | 31 +- lms/djangoapps/courseware/views/index.py | 5 +- lms/djangoapps/courseware/views/views.py | 21 +- .../rest_api/v1/tests/test_views.py | 2 +- lms/djangoapps/support/serializers.py | 6 +- lms/envs/common.py | 3 + lms/templates/courseware/courseware.html | 5 + .../dashboard/_dashboard_course_listing.html | 6 +- lms/templates/dates_banner.html | 108 ++ lms/urls.py | 14 + .../embargo/tests/test_middleware.py | 8 +- .../core/djangoapps/schedules/resolvers.py | 9 +- .../schedules/tests/test_resolvers.py | 4 +- .../djangoapps/user_authn/views/auto_auth.py | 6 +- .../user_authn/views/tests/test_auto_auth.py | 8 +- .../calendar_sync/views/calendar_sync.py | 4 +- .../views/course_bookmarks.py | 5 +- .../tests/test_course_expiration.py | 52 +- .../features/course_experience/__init__.py | 78 +- .../fixtures/course-home-fragment.html | 109 +++ .../fixtures/course-outline-fragment.html | 131 +++ .../fixtures/enrollment-button.html | 1 + .../fixtures/latest-update-fragment.html | 10 + .../fixtures/welcome-message-fragment.html | 32 + .../images/home_message_author.png | Bin 0 -> 4831 bytes .../course_experience/js/CourseGoals.js | 50 + .../static/course_experience/js/CourseHome.js | 160 +++ .../course_experience/js/CourseOutline.js | 112 +++ .../static/course_experience/js/Enrollment.js | 45 + .../course_experience/js/LatestUpdate.js | 15 + .../course_experience/js/WelcomeMessage.js | 65 ++ .../js/spec/CourseHome_spec.js | 70 ++ .../js/spec/CourseOutline_spec.js | 113 +++ .../js/spec/Currency_spec.js | 7 - .../js/spec/Enrollment_spec.js | 48 + .../js/spec/LatestUpdate_spec.js | 38 + .../js/spec/WelcomeMessage_spec.js | 107 ++ .../course-home-fragment.html | 236 +++++ .../course-messages-fragment.html | 40 + .../course-outline-fragment.html | 180 ++++ .../latest-update-fragment.html | 27 + .../welcome-message-fragment.html | 41 + .../tests/views/test_course_dates.py | 48 + .../tests/views/test_course_home.py | 926 +++++++++++++++++- .../tests/views/test_course_outline.py | 767 +++++++++++++++ .../tests/views/test_course_sock.py | 19 +- .../tests/views/test_masquerade.py | 25 +- .../tests/views/test_welcome_message.py | 80 ++ openedx/features/course_experience/urls.py | 40 +- openedx/features/course_experience/utils.py | 17 + .../course_experience/views/course_dates.py | 36 + .../course_experience/views/course_home.py | 242 ++++- .../views/course_home_messages.py | 232 +++++ .../course_experience/views/course_outline.py | 165 ++++ .../course_experience/views/course_updates.py | 7 +- .../course_experience/views/latest_update.py | 50 + .../views/welcome_message.py | 67 ++ openedx/features/course_search/__init__.py | 0 .../course_search/course-search-fragment.html | 73 ++ openedx/features/course_search/urls.py | 15 + .../features/course_search/views/__init__.py | 0 .../course_search/views/course_search.py | 68 ++ .../enterprise_support/tests/test_utils.py | 36 +- openedx/features/enterprise_support/utils.py | 38 +- webpack.common.config.js | 6 + 86 files changed, 5749 insertions(+), 196 deletions(-) create mode 100644 common/static/common/js/utils/clamp-html.js create mode 100644 common/static/common/js/utils/clamp-html.test.jsx create mode 100644 lms/djangoapps/course_goals/tests/test_views.py create mode 100644 lms/djangoapps/course_goals/urls.py create mode 100644 lms/djangoapps/course_goals/views.py create mode 100644 lms/templates/dates_banner.html create mode 100644 openedx/features/course_experience/static/course_experience/fixtures/course-home-fragment.html create mode 100644 openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html create mode 100644 openedx/features/course_experience/static/course_experience/fixtures/enrollment-button.html create mode 100644 openedx/features/course_experience/static/course_experience/fixtures/latest-update-fragment.html create mode 100644 openedx/features/course_experience/static/course_experience/fixtures/welcome-message-fragment.html create mode 100644 openedx/features/course_experience/static/course_experience/images/home_message_author.png create mode 100644 openedx/features/course_experience/static/course_experience/js/CourseGoals.js create mode 100644 openedx/features/course_experience/static/course_experience/js/CourseHome.js create mode 100644 openedx/features/course_experience/static/course_experience/js/CourseOutline.js create mode 100644 openedx/features/course_experience/static/course_experience/js/Enrollment.js create mode 100644 openedx/features/course_experience/static/course_experience/js/LatestUpdate.js create mode 100644 openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js create mode 100644 openedx/features/course_experience/static/course_experience/js/spec/CourseHome_spec.js create mode 100644 openedx/features/course_experience/static/course_experience/js/spec/CourseOutline_spec.js create mode 100644 openedx/features/course_experience/static/course_experience/js/spec/Enrollment_spec.js create mode 100644 openedx/features/course_experience/static/course_experience/js/spec/LatestUpdate_spec.js create mode 100644 openedx/features/course_experience/static/course_experience/js/spec/WelcomeMessage_spec.js create mode 100644 openedx/features/course_experience/templates/course_experience/course-home-fragment.html create mode 100644 openedx/features/course_experience/templates/course_experience/course-messages-fragment.html create mode 100644 openedx/features/course_experience/templates/course_experience/course-outline-fragment.html create mode 100644 openedx/features/course_experience/templates/course_experience/latest-update-fragment.html create mode 100644 openedx/features/course_experience/templates/course_experience/welcome-message-fragment.html create mode 100644 openedx/features/course_experience/tests/views/test_course_dates.py create mode 100644 openedx/features/course_experience/tests/views/test_course_outline.py create mode 100644 openedx/features/course_experience/tests/views/test_welcome_message.py create mode 100644 openedx/features/course_experience/views/course_home_messages.py create mode 100644 openedx/features/course_experience/views/course_outline.py create mode 100644 openedx/features/course_experience/views/latest_update.py create mode 100644 openedx/features/course_experience/views/welcome_message.py create mode 100644 openedx/features/course_search/__init__.py create mode 100644 openedx/features/course_search/templates/course_search/course-search-fragment.html create mode 100644 openedx/features/course_search/urls.py create mode 100644 openedx/features/course_search/views/__init__.py create mode 100644 openedx/features/course_search/views/course_search.py diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 44e2a49b1b..5d035f506e 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -104,8 +104,10 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest # Check whether we were correctly redirected if redirect: if has_started: - mfe_url = f'http://learning-mfe/course/{course.id}/home' - self.assertRedirects(response, mfe_url, fetch_redirect_response=False) + 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) + ) else: self.assertRedirects(response, reverse('dashboard')) else: diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 7958a480ed..4762c89e07 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -40,7 +40,6 @@ 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 @@ -414,7 +413,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(course_home_url(course_key)) + return redirect(reverse('openedx.course_experience.course_home', kwargs={'course_id': course_key})) else: return redirect(reverse('dashboard')) diff --git a/common/static/common/js/utils/clamp-html.js b/common/static/common/js/utils/clamp-html.js new file mode 100644 index 0000000000..4d123e669b --- /dev/null +++ b/common/static/common/js/utils/clamp-html.js @@ -0,0 +1,57 @@ +/** + * 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 +}; diff --git a/common/static/common/js/utils/clamp-html.test.jsx b/common/static/common/js/utils/clamp-html.test.jsx new file mode 100644 index 0000000000..0f08678446 --- /dev/null +++ b/common/static/common/js/utils/clamp-html.test.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { clampHtmlByWords } from './clamp-html'; + +let container; +const scriptTag = ''; +const styleTag = ''; + +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 aa ab b', 2, 'a aa…'], + ['a aa ab ac', 2, 'a aa…'], + ['a aa aaa', 2, 'a aa…'], + ['a aa aaa ab', 3, 'a aa aaa…'], + ['a aa ab b c', 4, 'a aa ab 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(
, container); + clampHtmlByWords(div, wordsLeft); + expect(div.innerHTML).toEqual(expected); + }); +}); diff --git a/lms/djangoapps/course_api/api.py b/lms/djangoapps/course_api/api.py index 726e49d095..4b5eeb942f 100644 --- a/lms/djangoapps/course_api/api.py +++ b/lms/djangoapps/course_api/api.py @@ -25,7 +25,6 @@ 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 @@ -286,7 +285,8 @@ def get_course_run_url(request, course_id): Returns: (string): the URL to the course run associated with course_id """ - return request.build_absolute_uri(course_home_url(course_id)) + course_run_url = reverse('openedx.course_experience.course_home', args=[course_id]) + return request.build_absolute_uri(course_run_url) def get_course_members(course_key): diff --git a/lms/djangoapps/course_goals/api.py b/lms/djangoapps/course_goals/api.py index 079dfc46b7..414eda0ba7 100644 --- a/lms/djangoapps/course_goals/api.py +++ b/lms/djangoapps/course_goals/api.py @@ -2,9 +2,31 @@ Course Goals Python API """ -from opaque_keys.edx.keys import CourseKey -from lms.djangoapps.course_goals.models import CourseGoal +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} + ) def add_course_goal(user, course_id, subscribed_to_reminders, days_per_week=None): @@ -39,3 +61,58 @@ 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 diff --git a/lms/djangoapps/course_goals/handlers.py b/lms/djangoapps/course_goals/handlers.py index 0c25746958..661d3220f5 100644 --- a/lms/djangoapps/course_goals/handlers.py +++ b/lms/djangoapps/course_goals/handlers.py @@ -17,6 +17,7 @@ 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, } diff --git a/lms/djangoapps/course_goals/models.py b/lms/djangoapps/course_goals/models.py index 73d69d6bd6..f2dfc8e4ef 100644 --- a/lms/djangoapps/course_goals/models.py +++ b/lms/djangoapps/course_goals/models.py @@ -8,6 +8,7 @@ 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 @@ -19,11 +20,12 @@ 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 -_GOAL_KEY_CHOICES = Choices( - ('certify', 'Earn a certificate'), - ('complete', 'Complete the course'), - ('explore', 'Explore the course'), - ('unsure', 'Not sure yet'), +# 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')), ) User = get_user_model() @@ -55,9 +57,7 @@ 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') - # 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) - + goal_key = models.CharField(max_length=100, choices=GOAL_KEY_CHOICES, default=GOAL_KEY_CHOICES.unsure) history = HistoricalRecords() def __str__(self): diff --git a/lms/djangoapps/course_goals/tests/test_views.py b/lms/djangoapps/course_goals/tests/test_views.py new file mode 100644 index 0000000000..a5fd30c6d2 --- /dev/null +++ b/lms/djangoapps/course_goals/tests/test_views.py @@ -0,0 +1,114 @@ +""" +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 diff --git a/lms/djangoapps/course_goals/urls.py b/lms/djangoapps/course_goals/urls.py new file mode 100644 index 0000000000..fd55934ef6 --- /dev/null +++ b/lms/djangoapps/course_goals/urls.py @@ -0,0 +1,16 @@ +""" +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')), +] diff --git a/lms/djangoapps/course_goals/views.py b/lms/djangoapps/course_goals/views.py new file mode 100644 index 0000000000..a2992a64c5 --- /dev/null +++ b/lms/djangoapps/course_goals/views.py @@ -0,0 +1,104 @@ +""" +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": , "goal_key": "", "user": ""} + + 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 diff --git a/lms/djangoapps/course_home_api/outline/tests/test_goals.py b/lms/djangoapps/course_home_api/outline/tests/test_goals.py index 699531c93d..aa7a97585f 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_goals.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_goals.py @@ -72,6 +72,7 @@ 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) @@ -88,6 +89,7 @@ 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) @@ -95,6 +97,7 @@ 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) @@ -102,6 +105,7 @@ 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) diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index ecbf803095..2714f3ece5 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -19,6 +19,7 @@ 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 @@ -147,6 +148,13 @@ 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) diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index 69460f13cb..863f9c07dc 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -6,6 +6,7 @@ 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 @@ -28,6 +29,9 @@ 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 @@ -50,6 +54,7 @@ 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): @@ -161,6 +166,10 @@ 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) @@ -375,6 +384,7 @@ 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') diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index bc47acf30b..8c8b8ad215 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -11,12 +11,29 @@ 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_key.deprecated and + (not course_home_legacy_is_active(course_key)) and COURSE_HOME_MICROFRONTEND_PROGRESS_TAB.is_enabled(course_key) and not DisableProgressPageStackedConfig.current(course_key=course_key).disabled ) diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index 4123da5f38..d9d20018f0 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -10,6 +10,8 @@ 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 _ @@ -17,14 +19,14 @@ from django.utils.translation import gettext_lazy from lazy import lazy from pytz import utc -from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price 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 +from openedx.core.djangolib.markup import HTML, Text from openedx.features.course_duration_limits.access import get_user_course_expiration_date -from openedx.features.course_experience import RELATIVE_DATES_FLAG +from openedx.features.course_experience import RELATIVE_DATES_FLAG, UPGRADE_DEADLINE_MESSAGE, CourseHomeMessages from common.djangoapps.student.models import CourseEnrollment from .context_processor import user_timezone_locale_prefs @@ -77,6 +79,12 @@ 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.""" @@ -272,6 +280,35 @@ 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): """ @@ -324,6 +361,34 @@ 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): """ @@ -447,6 +512,31 @@ 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( + '{learner_profile_name}' + ).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): """ @@ -518,6 +608,44 @@ 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( + '
' + '{upgrade_label}' + '
' + ).format( + upgrade_url=self.link, + upgrade_label=Text(_('Upgrade ({upgrade_price})')).format(upgrade_price=upgrade_price), + ) + ), + title=Text(upgrade_message) + ) + class VerificationDeadlineDate(DateSummary): """ diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 7f774faf62..911819e855 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -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_mfe_progress_tab_is_active +from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active, 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 +from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, default_course_url_name 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,8 +41,13 @@ class CoursewareTab(EnrolledTab): supports_preview_menu = True def __init__(self, tab_dict): - def link_func(course, _reverse_func): - return default_course_url(course.id) + 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') tab_dict['link_func'] = link_func super().__init__(tab_dict) diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index 244a5fd2b3..4b863fbc8e 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -9,6 +9,7 @@ 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 @@ -28,11 +29,13 @@ 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, course_home_url +from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG 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 common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory +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.track.tests import EventTrackingTestCase from common.djangoapps.util.milestones_helpers import get_prerequisite_courses_display, set_prerequisite_courses @@ -44,6 +47,7 @@ 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. @@ -120,7 +124,13 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra self.setup_user() url = reverse('about_course', args=[str(self.course.id)]) resp = self.client.get(url) - self.assertRedirects(resp, course_home_url(self.course.id), fetch_redirect_response=False) + # 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) @patch.dict(settings.FEATURES, {'ENABLE_COURSE_HOME_REDIRECT': False}) @patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True}) @@ -219,6 +229,7 @@ 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 @@ -262,6 +273,7 @@ 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 @@ -304,6 +316,7 @@ 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. @@ -343,6 +356,7 @@ 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 @@ -379,6 +393,7 @@ 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. @@ -418,3 +433,38 @@ class AboutSidebarHTMLTestCase(SharedModuleStoreTestCase): self.assertContains(resp, itemfactory_data) else: self.assertNotContains(resp, '
') + + +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) diff --git a/lms/djangoapps/courseware/tests/test_course_survey.py b/lms/djangoapps/courseware/tests/test_course_survey.py index d729add1b6..5cfae9047c 100644 --- a/lms/djangoapps/courseware/tests/test_course_survey.py +++ b/lms/djangoapps/courseware/tests/test_course_survey.py @@ -5,19 +5,20 @@ 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 @@ -77,7 +78,7 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe """ Helper method to assert that all known redirect points do redirect as expected """ - for view_name in ['courseware', 'progress']: + for view_name in ['courseware', 'openedx.course_experience.course_home', 'progress']: resp = self.client.get( reverse( view_name, @@ -94,7 +95,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', 'progress']: + for view_name in ['courseware', 'openedx.course_experience.course_home', 'progress']: resp = self.client.get( reverse( view_name, @@ -118,20 +119,17 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe def test_anonymous_user_visiting_course_with_survey(self): """ - Verifies that anonymous user going to the course with an unanswered survey is not - redirected to survey. + 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. """ self.logout() resp = self.client.get( reverse( - 'courseware', + 'openedx.course_experience.course_home', kwargs={'course_id': str(self.course.id)} ) ) - self.assertRedirects( - resp, - f'/login?next=/courses/{quote(str(self.course.id))}/courseware' - ) + assert resp.status_code == 200 def test_visiting_course_with_existing_answers(self): """ @@ -208,10 +206,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, - course_home_url(self.course_with_bogus_survey.id), - fetch_redirect_response=False, + reverse(course_home_path, kwargs={'course_id': str(self.course_with_bogus_survey.id)}) ) def test_visiting_survey_with_no_course_survey(self): @@ -225,10 +223,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, - course_home_url(self.course_without_survey.id), - fetch_redirect_response=False, + reverse(course_home_path, kwargs={'course_id': str(self.course_without_survey.id)}) ) def test_survey_xss(self): diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py index 741a02bea4..3e914168fd 100644 --- a/lms/djangoapps/courseware/tests/test_date_summary.py +++ b/lms/djangoapps/courseware/tests/test_date_summary.py @@ -9,6 +9,7 @@ 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 @@ -21,6 +22,7 @@ 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, @@ -43,8 +45,14 @@ 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 RELATIVE_DATES_FLAG +from openedx.features.course_experience import ( + DISABLE_UNIFIED_COURSE_TAB_FLAG, + RELATIVE_DATES_FLAG, + UPGRADE_DEADLINE_MESSAGE, + CourseHomeMessages +) from common.djangoapps.student.tests.factories import TEST_PASSWORD, CourseEnrollmentFactory, UserFactory @@ -74,6 +82,13 @@ 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.""" @@ -409,6 +424,53 @@ 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 = [ + '

Upcoming Dates

', + '
Upcoming Dates', + '
', + 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 +% if display_reset_dates_banner: + +% endif <%def name="course_name()"> <% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %> diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index b80ec5d77f..9d0cd188d2 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -12,10 +12,12 @@ 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 +from openedx.features.course_experience import course_home_url_name +from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url from common.djangoapps.student.helpers import ( VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, @@ -80,7 +82,7 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG % endif >
- <% course_target = course_home_url(course_overview.id) %> + <% 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 details')}