diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 23f1f5dcc8..89b0b0d303 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -1,6 +1,12 @@ +""" +Tests for course_modes views. +""" + +from datetime import datetime import unittest import decimal import ddt +import freezegun from mock import patch from django.conf import settings from django.core.urlresolvers import reverse @@ -9,6 +15,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from util.testing import UrlResetMixin from embargo.test_utils import restrict_course +from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory from course_modes.tests.factories import CourseModeFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory @@ -340,6 +347,21 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): self.assertNotContains(response, "Find courses") self.assertNotContains(response, "Schools & Partners") + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + @freezegun.freeze_time('2015-01-02') + def test_course_closed(self): + for mode in ["honor", "verified"]: + CourseModeFactory(mode_slug=mode, course_id=self.course.id) + + self.course.enrollment_end = datetime(2015, 01, 01) + modulestore().update_item(self.course, self.user.id) + + url = reverse('course_modes_choose', args=[unicode(self.course.id)]) + response = self.client.get(url) + # URL-encoded version of 1/1/15, 12:00 AM + redirect_url = reverse('dashboard') + '?course_closed=1%2F1%2F15%2C+12%3A00+AM' + self.assertRedirects(response, redirect_url) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase): diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index d40bb7d41c..5943b4962f 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -3,14 +3,16 @@ Views for the course_mode module """ import decimal +import urllib +from babel.dates import format_datetime from django.contrib.auth.decorators import login_required from django.core.urlresolvers import reverse from django.db import transaction from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import redirect from django.utils.decorators import method_decorator -from django.utils.translation import ugettext as _ +from django.utils.translation import get_language, to_locale, ugettext as _ from django.views.generic.base import View from ipware.ip import get_ip from opaque_keys.edx.keys import CourseKey @@ -108,6 +110,11 @@ class ChooseModeView(View): chosen_price = donation_for_course.get(unicode(course_key), None) course = modulestore().get_course(course_key) + if CourseEnrollment.is_enrollment_closed(request.user, course): + locale = to_locale(get_language()) + enrollment_end_date = format_datetime(course.enrollment_end, 'short', locale=locale) + params = urllib.urlencode({'course_closed': enrollment_end_date}) + return redirect('{0}?{1}'.format(reverse('dashboard'), params)) # When a credit mode is available, students will be given the option # to upgrade from a verified mode to a credit mode at the end of the course. diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 3d9a67abc9..c0abaccd7f 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -701,6 +701,10 @@ def dashboard(request): redirect_message = _("The course you are looking for does not start until {date}.").format( date=request.GET['notlive'] ) + elif 'course_closed' in request.GET: + redirect_message = _("The course you are looking for is closed for enrollment as of {date}.").format( + date=request.GET['course_closed'] + ) else: redirect_message = '' diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 02eb121205..1999c385ae 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -2,12 +2,13 @@ """ End-to-end tests for the LMS. """ - -from datetime import datetime +from datetime import datetime, timedelta from flaky import flaky from textwrap import dedent from unittest import skip from nose.plugins.attrib import attr +import pytz +import urllib from bok_choy.promise import EmptyPromise from ..helpers import ( @@ -1156,6 +1157,95 @@ class NotLiveRedirectTest(UniqueCourseTest): ) +@attr('shard_1') +class EnrollmentClosedRedirectTest(UniqueCourseTest): + """ + Test that a banner is shown when the user is redirected to the + dashboard after trying to view the track selection page for a + course after enrollment has ended. + """ + + def setUp(self): + """Create a course that is closed for enrollment, and sign in as a user.""" + super(EnrollmentClosedRedirectTest, self).setUp() + course = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + now = datetime.now(pytz.UTC) + course.add_course_details({ + 'enrollment_start': (now - timedelta(days=30)).isoformat(), + 'enrollment_end': (now - timedelta(days=1)).isoformat() + }) + course.install() + + # Add an honor mode to the course + ModeCreationPage(self.browser, self.course_id).visit() + + # Add a verified mode to the course + ModeCreationPage( + self.browser, + self.course_id, + mode_slug=u'verified', + mode_display_name=u'Verified Certificate', + min_price=10, + suggested_prices='10,20' + ).visit() + + def _assert_dashboard_message(self): + """ + Assert that the 'closed for enrollment' text is present on the + dashboard. + """ + page = DashboardPage(self.browser) + page.wait_for_page() + self.assertIn( + 'The course you are looking for is closed for enrollment', + page.banner_text + ) + + def test_redirect_banner(self): + """ + Navigate to the course info page, then check that we're on the + dashboard page with the appropriate message. + """ + AutoAuthPage(self.browser).visit() + url = BASE_URL + "/course_modes/choose/" + self.course_id + self.browser.get(url) + self._assert_dashboard_message() + + def test_login_redirect(self): + """ + Test that the user is correctly redirected after logistration when + attempting to enroll in a closed course. + """ + url = '{base_url}/register?{params}'.format( + base_url=BASE_URL, + params=urllib.urlencode({ + 'course_id': self.course_id, + 'enrollment_action': 'enroll', + 'email_opt_in': 'false' + }) + ) + self.browser.get(url) + register_page = CombinedLoginAndRegisterPage( + self.browser, + start_page="register", + course_id=self.course_id + ) + register_page.wait_for_page() + register_page.register( + email="email@example.com", + password="password", + username="username", + full_name="Test User", + country="US", + favorite_movie="Mad Max: Fury Road", + terms_of_service=True + ) + self._assert_dashboard_message() + + @attr('shard_1') class LMSLanguageTest(UniqueCourseTest): """ Test suite for the LMS Language """ diff --git a/lms/djangoapps/commerce/api/v0/tests/test_views.py b/lms/djangoapps/commerce/api/v0/tests/test_views.py index 3be720322c..26a161c522 100644 --- a/lms/djangoapps/commerce/api/v0/tests/test_views.py +++ b/lms/djangoapps/commerce/api/v0/tests/test_views.py @@ -1,4 +1,5 @@ """ Commerce API v0 view tests. """ +from datetime import datetime, timedelta import json import itertools from uuid import uuid4 @@ -10,6 +11,7 @@ from django.test import TestCase from django.test.utils import override_settings import mock from nose.plugins.attrib import attr +import pytz from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -25,6 +27,7 @@ from openedx.core.lib.django_test_client_utils import get_absolute_url from student.models import CourseEnrollment from student.tests.factories import CourseModeFactory from student.tests.tests import EnrollmentEventTestMixin +from xmodule.modulestore.django import modulestore @attr('shard_1') @@ -345,6 +348,16 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase) self.assertEqual(mock_update.called, is_opt_in) self.assertEqual(response.status_code, 200) + def test_closed_course(self): + """ + Ensure that the view does not attempt to create a basket for closed + courses. + """ + self.course.enrollment_end = datetime.now(pytz.UTC) - timedelta(days=1) + modulestore().update_item(self.course, self.user.id) # pylint:disable=no-member + with mock_create_basket(expect_called=False): + self.assertEqual(self._post_to_view().status_code, 406) + @attr('shard_1') @override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY) diff --git a/lms/djangoapps/commerce/api/v0/views.py b/lms/djangoapps/commerce/api/v0/views.py index 7a050b1ad0..993031acaa 100644 --- a/lms/djangoapps/commerce/api/v0/views.py +++ b/lms/djangoapps/commerce/api/v0/views.py @@ -100,6 +100,13 @@ class BasketsView(APIView): msg = Messages.ENROLLMENT_EXISTS.format(course_id=course_id, username=user.username) return DetailResponse(msg, status=HTTP_409_CONFLICT) + # Check to see if enrollment for this course is closed. + course = courses.get_course(course_key) + if CourseEnrollment.is_enrollment_closed(user, course): + msg = Messages.ENROLLMENT_CLOSED.format(course_id=course_id) + log.info(u'Unable to enroll user %s in closed course %s.', user.id, course_id) + return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE) + # If there is no audit or honor course mode, this most likely # a Prof-Ed course. Return an error so that the JS redirects # to track selection. diff --git a/lms/djangoapps/commerce/constants.py b/lms/djangoapps/commerce/constants.py index 447847fb9b..5b44b97fe9 100644 --- a/lms/djangoapps/commerce/constants.py +++ b/lms/djangoapps/commerce/constants.py @@ -17,3 +17,4 @@ class Messages(object): NO_HONOR_MODE = u'Course {course_id} does not have an honor mode.' NO_DEFAULT_ENROLLMENT_MODE = u'Course {course_id} does not have an honor or audit mode.' ENROLLMENT_EXISTS = u'User {username} is already enrolled in {course_id}.' + ENROLLMENT_CLOSED = u'Enrollment is closed for {course_id}.'