diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 18d5e669688c1ea98c54b818cc4d7a54af79255d..2c7cd36de5530c081a44083e2370d897f10dc7c6 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -22,6 +22,7 @@ from courseware.module_render import get_module from django.conf import settings from django.core.urlresolvers import reverse from django.http import Http404, QueryDict +from enrollment.api import get_course_enrollment_details from edxmako.shortcuts import render_to_string from fs.errors import ResourceNotFoundError from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException @@ -173,6 +174,30 @@ def can_self_enroll_in_course(course_key): return True +def course_open_for_self_enrollment(course_key): + """ + For a given course_key, determine if the course is available for enrollment + """ + # Check to see if learners can enroll themselves. + if not can_self_enroll_in_course(course_key): + return False + + # Check the enrollment start and end dates. + course_details = get_course_enrollment_details(unicode(course_key)) + now = datetime.now().replace(tzinfo=pytz.UTC) + start = course_details['enrollment_start'] + end = course_details['enrollment_end'] + + start = start if start is not None else now + end = end if end is not None else now + + # If we are not within the start and end date for enrollment. + if now < start or end < now: + return False + + return True + + def find_file(filesystem, dirs, filename): """ Looks for a filename in a list of dirs on a filesystem, in the specified order. diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index a8ccccc8c44ab9184be61c105a6bb5e8606ad056..f9229dbda57bc20a61f2fd8d1e751c441888ca46 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -4,8 +4,10 @@ Tests for course access """ import itertools +import datetime import ddt import mock +import pytz from django.conf import settings from django.core.urlresolvers import reverse from django.test.client import RequestFactory @@ -13,6 +15,7 @@ from django.test.utils import override_settings from nose.plugins.attrib import attr from courseware.courses import ( + course_open_for_self_enrollment, get_cms_block_link, get_cms_course_link, get_course_about_section, @@ -322,6 +325,52 @@ class CoursesRenderTest(ModuleStoreTestCase): self.assertIn("this module is temporarily unavailable", course_about) +class CourseEnrollmentOpenTests(ModuleStoreTestCase): + def setUp(self): + super(CourseEnrollmentOpenTests, self).setUp() + self.now = datetime.datetime.now().replace(tzinfo=pytz.UTC) + + def test_course_enrollment_open(self): + start = self.now - datetime.timedelta(days=1) + end = self.now + datetime.timedelta(days=1) + course = CourseFactory(enrollment_start=start, enrollment_end=end) + self.assertTrue(course_open_for_self_enrollment(course.id)) + + def test_course_enrollment_closed_future(self): + start = self.now + datetime.timedelta(days=1) + end = self.now + datetime.timedelta(days=2) + course = CourseFactory(enrollment_start=start, enrollment_end=end) + self.assertFalse(course_open_for_self_enrollment(course.id)) + + def test_course_enrollment_closed_past(self): + start = self.now - datetime.timedelta(days=2) + end = self.now - datetime.timedelta(days=1) + course = CourseFactory(enrollment_start=start, enrollment_end=end) + self.assertFalse(course_open_for_self_enrollment(course.id)) + + def test_course_enrollment_dates_missing(self): + course = CourseFactory() + self.assertTrue(course_open_for_self_enrollment(course.id)) + + def test_course_enrollment_dates_missing_start(self): + end = self.now + datetime.timedelta(days=1) + course = CourseFactory(enrollment_end=end) + self.assertTrue(course_open_for_self_enrollment(course.id)) + + end = self.now - datetime.timedelta(days=1) + course = CourseFactory(enrollment_end=end) + self.assertFalse(course_open_for_self_enrollment(course.id)) + + def test_course_enrollment_dates_missing_end(self): + start = self.now - datetime.timedelta(days=1) + course = CourseFactory(enrollment_start=start) + self.assertTrue(course_open_for_self_enrollment(course.id)) + + start = self.now + datetime.timedelta(days=1) + course = CourseFactory(enrollment_start=start) + self.assertFalse(course_open_for_self_enrollment(course.id)) + + @attr(shard=1) @ddt.ddt class CourseInstantiationTests(ModuleStoreTestCase): diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index e51675b54409570f4a3c88c79fb8a9e4bbc0a42b..691792de19f36bf0ef364d273cef3b7af1beab76 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -19,6 +19,7 @@ from courseware.access import has_access, has_ccx_coach_role from courseware.access_utils import check_course_open_for_learner from courseware.courses import ( can_self_enroll_in_course, + course_open_for_self_enrollment, get_course, get_course_overview_with_access, get_course_with_access, @@ -312,6 +313,7 @@ def course_info(request, course_id): 'request': request, 'masquerade_user': user, 'course_id': course_key.to_deprecated_string(), + 'url_to_enroll': CourseTabView.url_to_enroll(course_key), 'cache': None, 'course': course, 'staff_access': staff_access, @@ -321,7 +323,6 @@ def course_info(request, course_id): 'show_enroll_banner': show_enroll_banner, 'user_is_enrolled': user_is_enrolled, 'dates_fragment': dates_fragment, - 'url_to_enroll': CourseTabView.url_to_enroll(course_key), 'course_tools': course_tools, } context.update( @@ -449,15 +450,22 @@ class CourseTabView(EdxFragmentView): ) ) elif not is_enrolled and not is_staff: - PageLevelMessages.register_warning_message( - request, - Text(_('You must be enrolled in the course to see course content. {enroll_link}.')).format( - enroll_link=HTML('<a href="{url_to_enroll}">{enroll_link_label}</a>').format( - url_to_enroll=CourseTabView.url_to_enroll(course_key), - enroll_link_label=_("Enroll now"), + # Only show enroll button if course is open for enrollment. + if course_open_for_self_enrollment(course_key): + enroll_message = _('You must be enrolled in the course to see course content. \ + {enroll_link_start}Enroll now{enroll_link_end}.') + PageLevelMessages.register_warning_message( + request, + Text(enroll_message).format( + enroll_link_start=HTML('<button class="enroll-btn btn-link">'), + enroll_link_end=HTML('</button>') ) ) - ) + else: + PageLevelMessages.register_warning_message( + request, + Text(_('You must be enrolled in the course to see course content.')) + ) @staticmethod def handle_exceptions(request, course, exception): diff --git a/openedx/features/course_experience/static/course_experience/fixtures/enrollment-button.html b/openedx/features/course_experience/static/course_experience/fixtures/enrollment-button.html new file mode 100644 index 0000000000000000000000000000000000000000..1e55c18cb7e91d867f5a48ec124f2b8ff8ca6848 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/fixtures/enrollment-button.html @@ -0,0 +1 @@ +<button class="enroll-btn btn-link">Enroll Now</button> diff --git a/openedx/features/course_experience/static/course_experience/js/Enrollment.js b/openedx/features/course_experience/static/course_experience/js/Enrollment.js new file mode 100644 index 0000000000000000000000000000000000000000..e28fd5e06370aebc9158059816079f6c36f04a6e --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/js/Enrollment.js @@ -0,0 +1,45 @@ + +/* + * Course Enrollment on the Course Home page + */ +export class CourseEnrollment { // eslint-disable-line import/prefer-default-export + /** + * Redirect to a URL. Mainly useful for mocking out in tests. + * @param {string} url The URL to redirect to. + */ + static redirect(url) { + window.location.href = url; + } + + static refresh() { + window.location.reload(false); + } + + static createEnrollment(courseId) { + const data = JSON.stringify({ + course_details: { course_id: courseId }, + }); + const enrollmentAPI = '/api/enrollment/v1/enrollment'; + const trackSelection = '/course_modes/choose/'; + + return () => + $.ajax( + { + type: 'POST', + url: enrollmentAPI, + data, + contentType: 'application/json', + }).done(() => { + window.analytics.track('edx.bi.user.course-home.enrollment'); + CourseEnrollment.refresh(); + }).fail(() => { + // If the simple enrollment we attempted failed, go to the track selection page, + // which is better for handling more complex enrollment situations. + CourseEnrollment.redirect(trackSelection + courseId); + }); + } + + constructor(buttonClass, courseId) { + $(buttonClass).click(CourseEnrollment.createEnrollment(courseId)); + } +} diff --git a/openedx/features/course_experience/static/course_experience/js/spec/Enrollment_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/Enrollment_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6220736b89c1790b1bf57ca7ae5a9dfa2c746f12 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/js/spec/Enrollment_spec.js @@ -0,0 +1,48 @@ +/* globals $, loadFixtures */ + +import { + expectRequest, + requests as mockRequests, + respondWithJson, + respondWithError, +} from 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'; +import { CourseEnrollment } from '../Enrollment'; + + +describe('CourseEnrollment tests', () => { + describe('Ensure button behavior', () => { + const endpointUrl = '/api/enrollment/v1/enrollment'; + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + const enrollButtonClass = '.enroll-btn'; + + window.analytics = jasmine.createSpyObj('analytics', ['page', 'track', 'trackLink']); + + beforeEach(() => { + loadFixtures('course_experience/fixtures/enrollment-button.html'); + new CourseEnrollment('.enroll-btn', courseId); // eslint-disable-line no-new + }); + it('Verify that we reload on success', () => { + const requests = mockRequests(this); + $(enrollButtonClass).click(); + expectRequest( + requests, + 'POST', + endpointUrl, + `{"course_details":{"course_id":"${courseId}"}}`, + ); + spyOn(CourseEnrollment, 'refresh'); + respondWithJson(requests); + expect(CourseEnrollment.refresh).toHaveBeenCalled(); + expect(window.analytics.track).toHaveBeenCalled(); + requests.restore(); + }); + it('Verify that we redirect to track selection on fail', () => { + const requests = mockRequests(this); + $(enrollButtonClass).click(); + spyOn(CourseEnrollment, 'redirect'); + respondWithError(requests, 403); + expect(CourseEnrollment.redirect).toHaveBeenCalled(); + requests.restore(); + }); + }); +}); diff --git a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html index e8fb3d8dd8a96ca99d45825414f4b82a780700b6..93e38c167017c3947d9e58d73f8272d9e6514277 100644 --- a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html @@ -112,3 +112,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV courseToolLink: ".course-tool-link", }); </%static:webpack> + +<%static:webpack entry="Enrollment"> + new CourseEnrollment('.enroll-btn', '${course_key | n, js_escaped_string}'); +</%static:webpack> diff --git a/webpack.config.js b/webpack.config.js index b34e53ce7b94ae89b7b5781bc7b1937d47a1ab12..9ba5257aea6f70e4b47bf95b83d023c42d272063 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -23,6 +23,7 @@ var wpconfig = { CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js', CourseTalkReviews: './openedx/features/course_experience/static/course_experience/js/CourseTalkReviews.js', WelcomeMessage: './openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js', + Enrollment: './openedx/features/course_experience/static/course_experience/js/Enrollment.js', Import: './cms/static/js/features/import/factories/import.js' },