From f9b5a9173fb5fff396013f99215bd8e28b680453 Mon Sep 17 00:00:00 2001 From: Will Daly <will@edx.org> Date: Sun, 26 Oct 2014 15:15:37 -0400 Subject: [PATCH] Redirect users to the track selection page or ?next page when using third party auth Set marketing site logged in cookie from third party auth. --- common/djangoapps/course_modes/helpers.py | 0 common/djangoapps/course_modes/models.py | 65 ++++++ .../course_modes/tests/test_models.py | 17 ++ .../course_modes/tests/test_views.py | 60 +++--- common/djangoapps/course_modes/views.py | 41 ++-- common/djangoapps/student/helpers.py | 113 +++++++++++ .../student/tests/test_enrollment.py | 43 ---- .../tests/test_login_registration_forms.py | 185 ++++++++++++++++++ common/djangoapps/student/views.py | 60 ++---- .../djangoapps/third_party_auth/__init__.py | 15 ++ .../djangoapps/third_party_auth/pipeline.py | 171 +++++++++++++--- .../djangoapps/third_party_auth/settings.py | 3 +- .../third_party_auth/tests/specs/base.py | 47 +++++ .../tests/test_change_enrollment.py | 132 +++++++++++++ .../third_party_auth/tests/testutil.py | 2 + common/templates/course_modes/choose.html | 8 +- .../courseware/features/certificates.py | 2 +- lms/envs/test.py | 11 ++ lms/templates/login.html | 2 +- lms/templates/register.html | 2 +- .../verify_student/_verification_header.html | 4 +- 21 files changed, 815 insertions(+), 168 deletions(-) create mode 100644 common/djangoapps/course_modes/helpers.py create mode 100644 common/djangoapps/student/helpers.py create mode 100644 common/djangoapps/student/tests/test_login_registration_forms.py create mode 100644 common/djangoapps/third_party_auth/tests/test_change_enrollment.py diff --git a/common/djangoapps/course_modes/helpers.py b/common/djangoapps/course_modes/helpers.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 94466bf20d0..b0bf62afa97 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -182,6 +182,71 @@ class CourseMode(models.Model): return True return False + @classmethod + def can_auto_enroll(cls, course_id, modes_dict=None): + """Check whether students should be auto-enrolled in the course. + + If a course is behind a paywall (e.g. professional ed or white-label), + then users should NOT be auto-enrolled. Instead, the user will + be enrolled when he/she completes the payment flow. + + Otherwise, users can be enrolled in the default mode "honor" + with the option to upgrade later. + + Args: + course_id (CourseKey): The course to check. + + Keyword Args: + modes_dict (dict): If provided, use these course modes. + Useful for avoiding unnecessary database queries. + + Returns: + bool + + """ + if modes_dict is None: + modes_dict = cls.modes_for_course_dict(course_id) + + # Professional mode courses are always behind a paywall + if "professional" in modes_dict: + return False + + # White-label uses course mode honor with a price + # to indicate that the course is behind a paywall. + if cls.is_white_label(course_id, modes_dict=modes_dict): + return False + + # Check that the default mode is available. + return ("honor" in modes_dict) + + @classmethod + def is_white_label(cls, course_id, modes_dict=None): + """Check whether a course is a "white label" (paid) course. + + By convention, white label courses have a course mode slug "honor" + and a price. + + Args: + course_id (CourseKey): The course to check. + + Keyword Args: + modes_dict (dict): If provided, use these course modes. + Useful for avoiding unnecessary database queries. + + Returns: + bool + + """ + if modes_dict is None: + modes_dict = cls.modes_for_course_dict(course_id) + + # White-label uses course mode honor with a price + # to indicate that the course is behind a paywall. + if "honor" in modes_dict and len(modes_dict) == 1: + if modes_dict["honor"].min_price > 0 or modes_dict["honor"].suggested_prices != '': + return True + return False + @classmethod def min_course_price_for_currency(cls, course_id, currency): """ diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 88571d449d7..85a87b3a724 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -7,12 +7,14 @@ Replace this with more appropriate tests for your application. from datetime import datetime, timedelta import pytz +import ddt from opaque_keys.edx.locations import SlashSeparatedCourseKey from django.test import TestCase from course_modes.models import CourseMode, Mode +@ddt.ddt class CourseModeModelTest(TestCase): """ Tests for the CourseMode model @@ -146,3 +148,18 @@ class CourseModeModelTest(TestCase): honor.suggested_prices = '5, 10, 15' honor.save() self.assertTrue(CourseMode.has_payment_options(self.course_key)) + + @ddt.data( + ([], True), + ([("honor", 0), ("audit", 0), ("verified", 100)], True), + ([("honor", 100)], False), + ([("professional", 100)], False), + ) + @ddt.unpack + def test_can_auto_enroll(self, modes_and_prices, can_auto_enroll): + # Create the modes and min prices + for mode_slug, min_price in modes_and_prices: + self.create_mode(mode_slug, mode_slug.capitalize(), min_price=min_price) + + # Verify that we can or cannot auto enroll + self.assertEqual(CourseMode.can_auto_enroll(self.course_key), can_auto_enroll) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 30b242671b2..ed6d817ad33 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -32,41 +32,33 @@ class CourseModeViewTest(ModuleStoreTestCase): self.client.login(username=self.user.username, password="edx") @ddt.data( - # is_active?, enrollment_mode, upgrade?, redirect? - (True, 'verified', True, False), # User has an active verified enrollment and is trying to upgrade - (True, 'verified', False, True), # User has an active verified enrollment and is not trying to upgrade - (True, 'honor', True, False), # User has an active honor enrollment and is trying to upgrade - (True, 'honor', False, False), # User has an active honor enrollment and is not trying to upgrade - (True, 'audit', True, False), # User has an active audit enrollment and is trying to upgrade - (True, 'audit', False, False), # User has an active audit enrollment and is not trying to upgrade - (False, 'verified', True, True), # User has an inactive verified enrollment and is trying to upgrade - (False, 'verified', False, True), # User has an inactive verified enrollment and is not trying to upgrade - (False, 'honor', True, True), # User has an inactive honor enrollment and is trying to upgrade - (False, 'honor', False, True), # User has an inactive honor enrollment and is not trying to upgrade - (False, 'audit', True, True), # User has an inactive audit enrollment and is trying to upgrade - (False, 'audit', False, True), # User has an inactive audit enrollment and is not trying to upgrade + # is_active?, enrollment_mode, redirect? + (True, 'verified', True), + (True, 'honor', False), + (True, 'audit', False), + (False, 'verified', False), + (False, 'honor', False), + (False, 'audit', False), + (False, None, False), ) @ddt.unpack - def test_redirect_to_dashboard(self, is_active, enrollment_mode, upgrade, redirect): + def test_redirect_to_dashboard(self, is_active, enrollment_mode, redirect): # Create the course modes for mode in ('audit', 'honor', 'verified'): CourseModeFactory(mode_slug=mode, course_id=self.course.id) # Enroll the user in the test course - CourseEnrollmentFactory( - is_active=is_active, - mode=enrollment_mode, - course_id=self.course.id, - user=self.user - ) + if enrollment_mode is not None: + CourseEnrollmentFactory( + is_active=is_active, + mode=enrollment_mode, + course_id=self.course.id, + user=self.user + ) # Configure whether we're upgrading or not - get_params = {} - if upgrade: - get_params = {'upgrade': True} - url = reverse('course_modes_choose', args=[unicode(self.course.id)]) - response = self.client.get(url, get_params) + response = self.client.get(url) # Check whether we were correctly redirected if redirect: @@ -74,7 +66,19 @@ class CourseModeViewTest(ModuleStoreTestCase): else: self.assertEquals(response.status_code, 200) - def test_redirect_to_dashboard_no_enrollment(self): + def test_upgrade_copy(self): + # Create the course modes + for mode in ('audit', 'honor', 'verified'): + CourseModeFactory(mode_slug=mode, course_id=self.course.id) + + url = reverse('course_modes_choose', args=[unicode(self.course.id)]) + response = self.client.get(url, {"upgrade": True}) + + # Verify that the upgrade copy is displayed instead + # of the usual text. + self.assertContains(response, "Upgrade Your Enrollment") + + def test_no_enrollment(self): # Create the course modes for mode in ('audit', 'honor', 'verified'): CourseModeFactory(mode_slug=mode, course_id=self.course.id) @@ -83,7 +87,7 @@ class CourseModeViewTest(ModuleStoreTestCase): url = reverse('course_modes_choose', args=[unicode(self.course.id)]) response = self.client.get(url) - self.assertRedirects(response, reverse('dashboard')) + self.assertEquals(response.status_code, 200) @ddt.data( '', @@ -121,7 +125,7 @@ class CourseModeViewTest(ModuleStoreTestCase): # TODO: Fix it so that response.templates works w/ mako templates, and then assert # that the right template rendered - def test_professional_registration(self): + def test_professional_enrollment(self): # The only course mode is professional ed CourseModeFactory(mode_slug='professional', course_id=self.course.id) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index b70e9e30283..d4147b6fa3e 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -17,6 +17,7 @@ from course_modes.models import CourseMode from courseware.access import has_access from student.models import CourseEnrollment from opaque_keys.edx.locations import SlashSeparatedCourseKey +from opaque_keys.edx.keys import CourseKey from util.db import commit_on_success_with_read_committed from xmodule.modulestore.django import modulestore @@ -26,10 +27,10 @@ class ChooseModeView(View): When a get request is used, shows the selection page. - When a post request is used, assumes that it is a form submission + When a post request is used, assumes that it is a form submission from the selection page, parses the response, and then sends user to the next step in the flow. - + """ @method_decorator(login_required) @@ -48,28 +49,19 @@ class ChooseModeView(View): Response """ - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) - enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(request.user, course_key) - + course_key = CourseKey.from_string(course_id) + upgrade = request.GET.get('upgrade', False) request.session['attempting_upgrade'] = upgrade - # Students will already have an active course enrollment at this stage, - # but we should still show them the "choose your track" page so they have - # the option to enter the verification/payment flow. - go_to_dashboard = ( - not upgrade and enrollment_mode in ['verified', 'professional'] - ) - - if go_to_dashboard: - return redirect(reverse('dashboard')) - + enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(request.user, course_key) modes = CourseMode.modes_for_course_dict(course_key) # We assume that, if 'professional' is one of the modes, it is the *only* mode. # If we offer more modes alongside 'professional' in the future, this will need to route # to the usual "choose your track" page. - if "professional" in modes: + has_enrolled_professional = (enrollment_mode == "professional" and is_active) + if "professional" in modes and not has_enrolled_professional: return redirect( reverse( 'verify_student_show_requirements', @@ -77,14 +69,15 @@ class ChooseModeView(View): ) ) - # If a user's course enrollment is inactive at this stage, the track - # selection page may have been visited directly, so we should redirect - # the user to their dashboard. By the time the user gets here during the - # normal registration process, they will already have an activated enrollment; - # the button appearing on the track selection page only redirects the user to - # the dashboard, and we don't want the user to be confused when they click the - # honor button and are taken to their dashboard without being enrolled. - if not is_active: + # If there isn't a verified mode available, then there's nothing + # to do on this page. The user has almost certainly been auto-registered + # in the "honor" track by this point, so we send the user + # to the dashboard. + if not CourseMode.has_verified_mode(modes): + return redirect(reverse('dashboard')) + + # If a user has already paid, redirect them to the dashboard. + if is_active and enrollment_mode in CourseMode.VERIFIED_MODES: return redirect(reverse('dashboard')) donation_for_course = request.session.get("donation_for_course", {}) diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py new file mode 100644 index 00000000000..23bba3d0dd1 --- /dev/null +++ b/common/djangoapps/student/helpers.py @@ -0,0 +1,113 @@ +"""Helpers for the student app. """ +import time +from django.utils.http import cookie_date +from django.conf import settings +from django.core.urlresolvers import reverse +from opaque_keys.edx.keys import CourseKey +from course_modes.models import CourseMode +from third_party_auth import ( # pylint: disable=W0611 + pipeline, provider, + is_enabled as third_party_auth_enabled +) + + +def auth_pipeline_urls(auth_entry, redirect_url=None, course_id=None): + """Retrieve URLs for each enabled third-party auth provider. + + These URLs are used on the "sign up" and "sign in" buttons + on the login/registration forms to allow users to begin + authentication with a third-party provider. + + Optionally, we can redirect the user to an arbitrary + url after auth completes successfully. We use this + to redirect the user to a page that required login, + or to send users to the payment flow when enrolling + in a course. + + Args: + auth_entry (string): Either `pipeline.AUTH_ENTRY_LOGIN` or `pipeline.AUTH_ENTRY_REGISTER` + + Keyword Args: + redirect_url (unicode): If provided, send users to this URL + after they successfully authenticate. + + course_id (unicode): The ID of the course the user is enrolling in. + We use this to send users to the track selection page + if the course has a payment option. + Note that `redirect_url` takes precedence over the redirect + to the track selection page. + + Returns: + dict mapping provider names to URLs + + """ + if not third_party_auth_enabled(): + return {} + + if redirect_url is not None: + pipeline_redirect = redirect_url + elif course_id is not None: + # If the course is white-label (paid), then we send users + # to the shopping cart. (There is a third party auth pipeline + # step that will add the course to the cart.) + if CourseMode.is_white_label(CourseKey.from_string(course_id)): + pipeline_redirect = reverse("shoppingcart.views.show_cart") + + # Otherwise, send the user to the track selection page. + # The track selection page may redirect the user to the dashboard + # (if the only available mode is honor), or directly to verification + # (for professional ed). + else: + pipeline_redirect = reverse( + "course_modes_choose", + kwargs={'course_id': unicode(course_id)} + ) + else: + pipeline_redirect = None + + return { + provider.NAME: pipeline.get_login_url( + provider.NAME, auth_entry, + enroll_course_id=course_id, + redirect_url=pipeline_redirect + ) + for provider in provider.Registry.enabled() + } + + +def set_logged_in_cookie(request, response): + """Set a cookie indicating that the user is logged in. + + Some installations have an external marketing site configured + that displays a different UI when the user is logged in + (e.g. a link to the student dashboard instead of to the login page) + + Arguments: + request (HttpRequest): The request to the view, used to calculate + the cookie's expiration date based on the session expiration date. + response (HttpResponse): The response on which the cookie will be set. + + Returns: + HttpResponse + + """ + if request.session.get_expire_at_browser_close(): + max_age = None + expires = None + else: + max_age = request.session.get_expiry_age() + expires_time = time.time() + max_age + expires = cookie_date(expires_time) + + response.set_cookie( + settings.EDXMKTG_COOKIE_NAME, 'true', max_age=max_age, + expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, + path='/', secure=None, httponly=None, + ) + + return response + + +def is_logged_in_cookie_set(request): + """Check whether the request has the logged in cookie set. """ + return settings.EDXMKTG_COOKIE_NAME in request.COOKIES diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index 9565fc5a234..920803d34ca 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -11,12 +11,8 @@ from xmodule.modulestore.tests.django_utils import ( ModuleStoreTestCase, mixed_store_config ) from xmodule.modulestore.tests.factories import CourseFactory -from social.strategies.django_strategy import DjangoStrategy -from django.test.client import RequestFactory from student.tests.factories import UserFactory, CourseModeFactory from student.models import CourseEnrollment -from student.views import register_user -from third_party_auth.pipeline import change_enrollment as change_enrollment_third_party # Since we don't need any XML course fixtures, use a modulestore configuration # that disables the XML modulestore. @@ -97,45 +93,6 @@ class EnrollmentTest(ModuleStoreTestCase): self.assertTrue(is_active) self.assertEqual(course_mode, enrollment_mode) - def test_enroll_from_third_party_redirect(self): - """ - Test that, when a user visits the registration page *after* visiting a course, - if they go on to register and/or log in via third-party auth, they'll be enrolled - in that course. - - The testing here is a bit hackish, since we just ping the registration page, then - directly call the step in the third party pipeline that registers the user if - `registration_course_id` is set in the session, but it should catch any major breaks. - """ - self.client.logout() - self.client.get(reverse('register_user'), {'course_id': self.course.id}) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - dummy_request = RequestFactory().request() - dummy_request.session = self.client.session - strategy = DjangoStrategy(RequestFactory, request=dummy_request) - change_enrollment_third_party(is_register=True, strategy=strategy, user=self.user) - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) - - def test_no_prof_ed_third_party_autoenroll(self): - """ - Test that a user authenticating via third party auth while attempting to enroll - in a professional education course is not automatically enrolled in the course. - """ - self.client.logout() - - # Create the course mode required for this test case - CourseModeFactory(course_id=self.course.id, mode_slug='professional') - - self.client.get(reverse('register_user'), {'course_id': self.course.id}) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - dummy_request = RequestFactory().request() - dummy_request.session = self.client.session - strategy = DjangoStrategy(RequestFactory, request=dummy_request) - change_enrollment_third_party(is_register=True, strategy=strategy, user=self.user) - - # Verify that the user has not been enrolled in the course - self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) - def test_unenroll(self): # Enroll the student in the course CourseEnrollment.enroll(self.user, self.course.id, mode="honor") diff --git a/common/djangoapps/student/tests/test_login_registration_forms.py b/common/djangoapps/student/tests/test_login_registration_forms.py new file mode 100644 index 00000000000..598c9200210 --- /dev/null +++ b/common/djangoapps/student/tests/test_login_registration_forms.py @@ -0,0 +1,185 @@ +"""Tests for the login and registration form rendering. """ +import urllib +import unittest +from mock import patch +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test import TestCase +import ddt +from django.test.utils import override_settings +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import CourseModeFactory +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, mixed_store_config +) + + +# This relies on third party auth being enabled and configured +# in the test settings. See the setting `THIRD_PARTY_AUTH` +# and the feature flag `ENABLE_THIRD_PARTY_AUTH` +THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"] +THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"] + +# Since we don't need any XML course fixtures, use a modulestore configuration +# that disables the XML modulestore. +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) + + +def _third_party_login_url(backend_name, auth_entry, course_id=None, redirect_url=None): + """Construct the login URL to start third party authentication. """ + params = [("auth_entry", auth_entry)] + if redirect_url: + params.append(("next", redirect_url)) + if course_id: + params.append(("enroll_course_id", course_id)) + + return u"{url}?{params}".format( + url=reverse("social:begin", kwargs={"backend": backend_name}), + params=urllib.urlencode(params) + ) + + +@ddt.ddt +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class LoginFormTest(ModuleStoreTestCase): + """Test rendering of the login form. """ + + def setUp(self): + self.url = reverse("signin_user") + self.course = CourseFactory.create() + self.course_id = unicode(self.course.id) + self.course_modes_url = reverse("course_modes_choose", kwargs={"course_id": self.course_id}) + self.courseware_url = reverse("courseware", args=[self.course_id]) + + @patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) + @ddt.data(THIRD_PARTY_AUTH_PROVIDERS) + def test_third_party_auth_disabled(self, provider_name): + response = self.client.get(self.url) + self.assertNotContains(response, provider_name) + + @ddt.data(*THIRD_PARTY_AUTH_BACKENDS) + def test_third_party_auth_no_course_id(self, backend_name): + response = self.client.get(self.url) + expected_url = _third_party_login_url(backend_name, "login") + self.assertContains(response, expected_url) + + @ddt.data(*THIRD_PARTY_AUTH_BACKENDS) + def test_third_party_auth_with_course_id(self, backend_name): + # Provide a course ID to the login page, simulating what happens + # when a user tries to enroll in a course without being logged in + response = self.client.get(self.url, {"course_id": self.course_id}) + + # Expect that the course ID is added to the third party auth entry + # point, so that the pipeline will enroll the student and + # redirect the student to the track selection page. + expected_url = _third_party_login_url( + backend_name, + "login", + course_id=self.course_id, + redirect_url=self.course_modes_url + ) + self.assertContains(response, expected_url) + + @ddt.data(*THIRD_PARTY_AUTH_BACKENDS) + def test_third_party_auth_with_white_label_course(self, backend_name): + # Set the course mode to honor with a min price, + # indicating that the course is behind a paywall. + CourseModeFactory.create( + course_id=self.course.id, + mode_slug="honor", + mode_display_name="Honor", + min_price=100 + ) + + # Expect that we're redirected to the shopping cart + # instead of to the track selection page. + response = self.client.get(self.url, {"course_id": self.course_id}) + expected_url = _third_party_login_url( + backend_name, + "login", + course_id=self.course_id, + redirect_url=reverse("shoppingcart.views.show_cart") + ) + self.assertContains(response, expected_url) + + @ddt.data(*THIRD_PARTY_AUTH_BACKENDS) + def test_third_party_auth_with_redirect_url(self, backend_name): + # Try to access courseware while logged out, expecting to be + # redirected to the login page. + response = self.client.get(self.courseware_url, follow=True) + self.assertRedirects( + response, + u"{url}?next={redirect_url}".format( + url=reverse("accounts_login"), + redirect_url=self.courseware_url + ) + ) + + # Verify that the third party auth URLs include the redirect URL + # The third party auth pipeline will redirect to this page + # once the user successfully authenticates. + expected_url = _third_party_login_url( + backend_name, + "login", + redirect_url=self.courseware_url + ) + self.assertContains(response, expected_url) + + +@ddt.ddt +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class RegisterFormTest(TestCase): + """Test rendering of the registration form. """ + + def setUp(self): + self.url = reverse("register_user") + self.course = CourseFactory.create() + self.course_id = unicode(self.course.id) + self.course_modes_url = reverse("course_modes_choose", kwargs={"course_id": self.course_id}) + + @patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) + @ddt.data(*THIRD_PARTY_AUTH_PROVIDERS) + def test_third_party_auth_disabled(self, provider_name): + response = self.client.get(self.url) + self.assertNotContains(response, provider_name) + + @ddt.data(*THIRD_PARTY_AUTH_BACKENDS) + def test_register_third_party_auth_no_course_id(self, backend_name): + response = self.client.get(self.url) + expected_url = _third_party_login_url(backend_name, "register") + self.assertContains(response, expected_url) + + @ddt.data(*THIRD_PARTY_AUTH_BACKENDS) + def test_register_third_party_auth_with_course_id(self, backend_name): + response = self.client.get(self.url, {"course_id": self.course_id}) + expected_url = _third_party_login_url( + backend_name, + "register", + course_id=self.course_id, + redirect_url=self.course_modes_url + ) + self.assertContains(response, expected_url) + + @ddt.data(*THIRD_PARTY_AUTH_BACKENDS) + def test_third_party_auth_with_white_label_course(self, backend_name): + # Set the course mode to honor with a min price, + # indicating that the course is behind a paywall. + CourseModeFactory.create( + course_id=self.course.id, + mode_slug="honor", + mode_display_name="Honor", + min_price=100 + ) + + # Expect that we're redirected to the shopping cart + # instead of to the track selection page. + response = self.client.get(self.url, {"course_id": self.course_id}) + expected_url = _third_party_login_url( + backend_name, + "register", + course_id=self.course_id, + redirect_url=reverse("shoppingcart.views.show_cart") + ) + self.assertContains(response, expected_url) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index eb23c1f8765..ab33ba9fea2 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -93,6 +93,7 @@ from util.password_policy_validators import ( ) from third_party_auth import pipeline, provider +from student.helpers import auth_pipeline_urls, set_logged_in_cookie from xmodule.error_module import ErrorDescriptor from shoppingcart.models import CourseRegistrationCode @@ -352,13 +353,15 @@ def signin_user(request): if request.user.is_authenticated(): return redirect(reverse('dashboard')) + course_id = request.GET.get('course_id') context = { - 'course_id': request.GET.get('course_id'), + 'course_id': course_id, 'enrollment_action': request.GET.get('enrollment_action'), # Bool injected into JS to submit form if we're inside a running third- # party auth pipeline; distinct from the actual instance of the running # pipeline, if any. 'pipeline_running': 'true' if pipeline.running(request) else 'false', + 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, course_id=course_id), 'platform_name': microsite.get_value( 'platform_name', settings.PLATFORM_NAME @@ -380,12 +383,15 @@ def register_user(request, extra_context=None): # and registration is disabled. return external_auth.views.redirect_with_get('root', request.GET) + course_id = request.GET.get('course_id') + context = { - 'course_id': request.GET.get('course_id'), + 'course_id': course_id, 'email': '', 'enrollment_action': request.GET.get('enrollment_action'), 'name': '', 'running_pipeline': None, + 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, course_id=course_id), 'platform_name': microsite.get_value( 'platform_name', settings.PLATFORM_NAME @@ -394,10 +400,6 @@ def register_user(request, extra_context=None): 'username': '', } - # We save this so, later on, we can determine what course motivated a user's signup - # if they actually complete the registration process - request.session['registration_course_id'] = context['course_id'] - if extra_context is not None: context.update(extra_context) @@ -798,14 +800,9 @@ def change_enrollment(request, check_access=True): available_modes = CourseMode.modes_for_course_dict(course_id) - # Handle professional ed as a special case. - # If professional ed is included in the list of available modes, - # then do NOT automatically enroll the student (we want them to pay first!) - # By convention, professional ed should be the *only* available course mode, - # if it's included at all -- anything else is a misconfiguration. But if someone - # messes up and adds an additional course mode, we err on the side of NOT - # accidentally giving away free courses. - if "professional" not in available_modes: + # Check that auto enrollment is allowed for this course + # (= the course is NOT behind a paywall) + if CourseMode.can_auto_enroll(course_id): # Enroll the user using the default mode (honor) # We're assuming that users of the course enrollment table # will NOT try to look up the course enrollment model @@ -821,7 +818,7 @@ def change_enrollment(request, check_access=True): # then send the user to the choose your track page. # (In the case of professional ed, this will redirect to a page that # funnels users directly into the verification / payment flow) - if len(available_modes) > 1 or "professional" in available_modes: + if CourseMode.has_verified_mode(available_modes): return HttpResponse( reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)}) ) @@ -902,6 +899,7 @@ def accounts_login(request): context = { 'pipeline_running': 'false', + 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to), 'platform_name': settings.PLATFORM_NAME, } return render_to_response('login.html', context) @@ -1053,14 +1051,12 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un 'username': username, }) - # If the user entered the flow via a specific course page, we track that - registration_course_id = request.session.get('registration_course_id') analytics.track( user.id, "edx.bi.user.account.authenticated", { 'category': "conversion", - 'label': registration_course_id, + 'label': request.POST.get('course_id'), 'provider': None }, context={ @@ -1069,7 +1065,6 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un } } ) - request.session['registration_course_id'] = None if user is not None and user.is_active: try: @@ -1097,25 +1092,9 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un "redirect_url": redirect_url, }) - # set the login cookie for the edx marketing site - # we want this cookie to be accessed via javascript - # so httponly is set to None - - if request.session.get_expire_at_browser_close(): - max_age = None - expires = None - else: - max_age = request.session.get_expiry_age() - expires_time = time.time() + max_age - expires = cookie_date(expires_time) - - response.set_cookie( - settings.EDXMKTG_COOKIE_NAME, 'true', max_age=max_age, - expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, - path='/', secure=None, httponly=None, - ) - - return response + # Ensure that the external marketing site can + # detect that the user is logged in. + return set_logged_in_cookie(request, response) if settings.FEATURES['SQUELCH_PII_IN_LOGS']: AUDIT_LOG.warning(u"Login failed - Account not active for user.id: {0}, resending activation".format(user.id)) @@ -1130,6 +1109,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un }) # TODO: this should be status code 400 # pylint: disable=fixme + @ensure_csrf_cookie def logout_user(request): """ @@ -1536,13 +1516,12 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend')) provider_name = current_provider.NAME - registration_course_id = request.session.get('registration_course_id') analytics.track( user.id, "edx.bi.user.account.registered", { 'category': 'conversion', - 'label': registration_course_id, + 'label': request.POST.get('course_id'), 'provider': provider_name }, context={ @@ -1551,7 +1530,6 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many } } ) - request.session['registration_course_id'] = None create_comments_service_user(user) diff --git a/common/djangoapps/third_party_auth/__init__.py b/common/djangoapps/third_party_auth/__init__.py index e69de29bb2d..c30f0076fab 100644 --- a/common/djangoapps/third_party_auth/__init__.py +++ b/common/djangoapps/third_party_auth/__init__.py @@ -0,0 +1,15 @@ +"""Third party authentication. """ + +from microsite_configuration import microsite + + +def is_enabled(): + """Check whether third party authentication has been enabled. """ + + # We do this import internally to avoid initializing settings prematurely + from django.conf import settings + + return microsite.get_value( + "ENABLE_THIRD_PARTY_AUTH", + settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH") + ) diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index a7ce75aae55..c4730d6dc3e 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -59,6 +59,8 @@ See http://psa.matiasaguirre.net/docs/pipeline.html for more docs. import random import string # pylint: disable-msg=deprecated-module +from collections import OrderedDict +import urllib import analytics from eventtracking import tracker @@ -69,7 +71,15 @@ from social.apps.django_app.default import models from social.exceptions import AuthException from social.pipeline import partial -from student.models import CourseMode, CourseEnrollment, CourseEnrollmentException +import student +from shoppingcart.models import Order, PaidCourseRegistration # pylint: disable=F0401 +from shoppingcart.exceptions import ( # pylint: disable=F0401 + CourseDoesNotExistException, + ItemAlreadyInCartException, + AlreadyEnrolledInCourseException +) +from student.models import CourseEnrollment, CourseEnrollmentException +from course_modes.models import CourseMode from opaque_keys.edx.keys import CourseKey from logging import getLogger @@ -77,7 +87,24 @@ from logging import getLogger from . import provider +# These are the query string params you can pass +# to the URL that starts the authentication process. +# +# `AUTH_ENTRY_KEY` is required and indicates how the user +# enters the authentication process. +# +# `AUTH_REDIRECT_KEY` provides an optional URL to redirect +# to upon successful authentication +# (if not provided, defaults to `_SOCIAL_AUTH_LOGIN_REDIRECT_URL`) +# +# `AUTH_ENROLL_COURSE_ID_KEY` provides the course ID that a student +# is trying to enroll in, used to generate analytics events +# and auto-enroll students. + AUTH_ENTRY_KEY = 'auth_entry' +AUTH_REDIRECT_KEY = 'next' +AUTH_ENROLL_COURSE_ID_KEY = 'enroll_course_id' + AUTH_ENTRY_DASHBOARD = 'dashboard' AUTH_ENTRY_LOGIN = 'login' AUTH_ENTRY_PROFILE = 'profile' @@ -177,15 +204,25 @@ def _get_enabled_provider_by_name(provider_name): return enabled_provider -def _get_url(view_name, backend_name, auth_entry=None): +def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, enroll_course_id=None): """Creates a URL to hook into social auth endpoints.""" kwargs = {'backend': backend_name} url = reverse(view_name, kwargs=kwargs) + query_params = OrderedDict() if auth_entry: - url += '?%s=%s' % (AUTH_ENTRY_KEY, auth_entry) + query_params[AUTH_ENTRY_KEY] = auth_entry + + if redirect_url: + query_params[AUTH_REDIRECT_KEY] = redirect_url + + if enroll_course_id: + query_params[AUTH_ENROLL_COURSE_ID_KEY] = enroll_course_id - return url + return u"{url}?{params}".format( + url=url, + params=urllib.urlencode(query_params) + ) def get_complete_url(backend_name): @@ -226,7 +263,7 @@ def get_disconnect_url(provider_name): return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name) -def get_login_url(provider_name, auth_entry): +def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id=None): """Gets the login URL for the endpoint that kicks off auth with a provider. Args: @@ -236,6 +273,13 @@ def get_login_url(provider_name, auth_entry): for the auth pipeline. Used by the pipeline for later branching. Must be one of _AUTH_ENTRY_CHOICES. + Keyword Args: + redirect_url (string): If provided, redirect to this URL at the end + of the authentication process. + + enroll_course_id (string): If provided, auto-enroll the user in this + course upon successful authentication. + Returns: String. URL that starts the auth pipeline for a provider. @@ -244,7 +288,13 @@ def get_login_url(provider_name, auth_entry): """ assert auth_entry in _AUTH_ENTRY_CHOICES enabled_provider = _get_enabled_provider_by_name(provider_name) - return _get_url('social:begin', enabled_provider.BACKEND_CLASS.name, auth_entry=auth_entry) + return _get_url( + 'social:begin', + enabled_provider.BACKEND_CLASS.name, + auth_entry=auth_entry, + redirect_url=redirect_url, + enroll_course_id=enroll_course_id + ) def get_duplicate_provider(messages): @@ -378,8 +428,54 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar if is_register and user_unset: return redirect('/register', name='register_user') + @partial.partial -def login_analytics(*args, **kwargs): +def set_logged_in_cookie(backend=None, user=None, request=None, *args, **kwargs): + """This pipeline step sets the "logged in" cookie for authenticated users. + + Some installations have a marketing site front-end separate from + edx-platform. Those installations sometimes display different + information for logged in versus anonymous users (e.g. a link + to the student dashboard instead of the login page.) + + Since social auth uses Django's native `login()` method, it bypasses + our usual login view that sets this cookie. For this reason, we need + to set the cookie ourselves within the pipeline. + + The procedure for doing this is a little strange. On the one hand, + we need to send a response to the user in order to set the cookie. + On the other hand, we don't want to drop the user out of the pipeline. + + For this reason, we send a redirect back to the "complete" URL, + so users immediately re-enter the pipeline. The redirect response + contains a header that sets the logged in cookie. + + If the user is not logged in, or the logged in cookie is already set, + the function returns `None`, indicating that control should pass + to the next pipeline step. + + """ + if user is not None and user.is_authenticated(): + if request is not None: + # Check that the cookie isn't already set. + # This ensures that we allow the user to continue to the next + # pipeline step once he/she has the cookie set by this step. + has_cookie = student.helpers.is_logged_in_cookie_set(request) + if not has_cookie: + try: + redirect_url = get_complete_url(backend.name) + except ValueError: + # If for some reason we can't get the URL, just skip this step + # This may be overly paranoid, but it's far more important that + # the user log in successfully than that the cookie is set. + pass + else: + response = redirect(redirect_url) + return student.helpers.set_logged_in_cookie(request, response) + + +@partial.partial +def login_analytics(strategy, *args, **kwargs): """ Sends login info to Segment.io """ event_name = None @@ -396,14 +492,13 @@ def login_analytics(*args, **kwargs): event_name = action_to_event_name[action] if event_name is not None: - registration_course_id = kwargs['request'].session.get('registration_course_id') tracking_context = tracker.get_tracker().resolve_context() analytics.track( kwargs['user'].id, event_name, { 'category': "conversion", - 'label': registration_course_id, + 'label': strategy.session_get('enroll_course_id'), 'provider': getattr(kwargs['backend'], 'name') }, context={ @@ -413,22 +508,54 @@ def login_analytics(*args, **kwargs): } ) -#@partial.partial -def change_enrollment(*args, **kwargs): - """ - If the user accessed the third party auth flow after trying to register for - a course, we automatically log them into that course. - """ - if kwargs['strategy'].session_get('registration_course_id'): - course_id = CourseKey.from_string( - kwargs['strategy'].session_get('registration_course_id') - ) - available_modes = CourseMode.modes_for_course_dict(course_id) - if 'honor' in available_modes: +@partial.partial +def change_enrollment(strategy, user=None, *args, **kwargs): + """Enroll a user in a course. + + If a user entered the authentication flow when trying to enroll + in a course, then attempt to enroll the user. + We will try to do this if the pipeline was started with the + querystring param `enroll_course_id`. + + In the following cases, we can't enroll the user: + * The course does not have an honor mode. + * The course has an honor mode with a minimum price. + * The course is not yet open for enrollment. + * The course does not exist. + + If we can't enroll the user now, then skip this step. + For paid courses, users will be redirected to the payment flow + upon completion of the authentication pipeline + (configured using the ?next parameter to the third party auth login url). + + """ + enroll_course_id = strategy.session_get('enroll_course_id') + if enroll_course_id: + course_id = CourseKey.from_string(enroll_course_id) + modes = CourseMode.modes_for_course_dict(course_id) + if CourseMode.can_auto_enroll(course_id, modes_dict=modes): try: - CourseEnrollment.enroll(kwargs['user'], course_id) + CourseEnrollment.enroll(user, course_id, check_access=True) except CourseEnrollmentException: pass except Exception as ex: logger.exception(ex) + + # Handle white-label courses as a special case + # If a course is white-label, we should add it to the shopping cart. + elif CourseMode.is_white_label(course_id, modes_dict=modes): + try: + cart = Order.get_cart_for_user(user) + PaidCourseRegistration.add_to_order(cart, course_id) + except ( + CourseDoesNotExistException, + ItemAlreadyInCartException, + AlreadyEnrolledInCourseException + ): + pass + # It's more important to complete login than to + # ensure that the course was added to the shopping cart. + # Log errors, but don't stop the authentication pipeline. + except Exception as ex: + logger.exception(ex) diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index ecb430581f8..421d335b2c2 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -46,7 +46,7 @@ If true, it: from . import provider -_FIELDS_STORED_IN_SESSION = ['auth_entry'] +_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next', 'enroll_course_id'] _MIDDLEWARE_CLASSES = ( 'third_party_auth.middleware.ExceptionMiddleware', ) @@ -116,6 +116,7 @@ def _set_global_settings(django_settings): 'social.pipeline.social_auth.associate_user', 'social.pipeline.social_auth.load_extra_data', 'social.pipeline.user.user_details', + 'third_party_auth.pipeline.set_logged_in_cookie', 'third_party_auth.pipeline.login_analytics', 'third_party_auth.pipeline.change_enrollment', ) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 6d5ec1e3533..071700376d9 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -394,6 +394,19 @@ class IntegrationTest(testutil.TestCase, test.TestCase): """Gets a user by email, using the given strategy.""" return strategy.storage.user.user_model().objects.get(email=email) + def assert_logged_in_cookie_redirect(self, response): + """Verify that the user was redirected in order to set the logged in cookie. """ + self.assertEqual(response.status_code, 302) + self.assertEqual( + response["Location"], + pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name) + ) + self.assertEqual(response.cookies[django_settings.EDXMKTG_COOKIE_NAME].value, 'true') + + def set_logged_in_cookie(self, request): + """Simulate setting the marketing site cookie on the request. """ + request.COOKIES[django_settings.EDXMKTG_COOKIE_NAME] = 'true' + # Actual tests, executed once per child. def test_canceling_authentication_redirects_to_login_when_auth_entry_login(self): @@ -430,6 +443,16 @@ class IntegrationTest(testutil.TestCase, test.TestCase): self.assert_dashboard_response_looks_correct(student_views.dashboard(request), request.user, linked=False) self.assert_social_auth_does_not_exist_for_user(request.user, strategy) + # We should be redirected back to the complete page, setting + # the "logged in" cookie for the marketing site. + self.assert_logged_in_cookie_redirect(actions.do_complete( + request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access + redirect_field_name=auth.REDIRECT_FIELD_NAME + )) + + # Set the cookie and try again + self.set_logged_in_cookie(request) + # Fire off the auth pipeline to link. self.assert_redirect_to_dashboard_looks_correct(actions.do_complete( request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access @@ -449,6 +472,9 @@ class IntegrationTest(testutil.TestCase, test.TestCase): strategy, 'user@example.com', 'password', self.get_username()) self.assert_social_auth_exists_for_user(user, strategy) + # We're already logged in, so simulate that the cookie is set correctly + self.set_logged_in_cookie(request) + # Instrument the pipeline to get to the dashboard with the full # expected state. self.client.get( @@ -561,6 +587,17 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # redirects to /auth/complete. In the browser ajax handlers will # redirect the user to the dashboard; we invoke it manually here. self.assert_json_success_response_looks_correct(student_views.login_user(strategy.request)) + + # We should be redirected back to the complete page, setting + # the "logged in" cookie for the marketing site. + self.assert_logged_in_cookie_redirect(actions.do_complete( + request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access + redirect_field_name=auth.REDIRECT_FIELD_NAME + )) + + # Set the cookie and try again + self.set_logged_in_cookie(request) + self.assert_redirect_to_dashboard_looks_correct( actions.do_complete(strategy, social_views._do_login, user=user)) self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user) @@ -652,6 +689,16 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # social auth. self.assert_social_auth_does_not_exist_for_user(created_user, strategy) + # We should be redirected back to the complete page, setting + # the "logged in" cookie for the marketing site. + self.assert_logged_in_cookie_redirect(actions.do_complete( + request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access + redirect_field_name=auth.REDIRECT_FIELD_NAME + )) + + # Set the cookie and try again + self.set_logged_in_cookie(request) + # Pick the pipeline back up. This will create the account association # and send the user to the dashboard, where the association will be # displayed. diff --git a/common/djangoapps/third_party_auth/tests/test_change_enrollment.py b/common/djangoapps/third_party_auth/tests/test_change_enrollment.py new file mode 100644 index 00000000000..73ecd1022b4 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_change_enrollment.py @@ -0,0 +1,132 @@ +"""Tests for the change enrollment step of the pipeline. """ + +import datetime +import unittest +import ddt +import pytz +from third_party_auth import pipeline +from shoppingcart.models import Order, PaidCourseRegistration # pylint: disable=F0401 +from social.apps.django_app import utils as social_utils +from django.conf import settings +from django.contrib.sessions.backends import cache +from django.test import RequestFactory +from django.test.utils import override_settings +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, CourseModeFactory +from student.models import CourseEnrollment +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, mixed_store_config +) + + +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) + +THIRD_PARTY_AUTH_CONFIGURED = ( + settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and + getattr(settings, 'THIRD_PARTY_AUTH', {}) +) + + +@unittest.skipUnless(THIRD_PARTY_AUTH_CONFIGURED, "Third party auth must be configured") +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +@ddt.ddt +class PipelineEnrollmentTest(ModuleStoreTestCase): + """Test that the pipeline auto-enrolls students upon successful authentication. """ + + BACKEND_NAME = "google-oauth2" + + def setUp(self): + """Create a test course and user. """ + super(PipelineEnrollmentTest, self).setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create() + + @ddt.data( + ([], "honor"), + (["honor", "verified", "audit"], "honor"), + (["professional"], None) + ) + @ddt.unpack + def test_auto_enroll_step(self, course_modes, enrollment_mode): + # Create the course modes for the test case + for mode_slug in course_modes: + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=mode_slug, + mode_display_name=mode_slug.capitalize() + ) + + # Simulate the pipeline step, passing in a course ID + # to indicate that the user was trying to enroll + # when they started the auth process. + strategy = self._fake_strategy() + strategy.session_set('enroll_course_id', unicode(self.course.id)) + + result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=E1111,E1124 + self.assertEqual(result, {}) + + # Check that the user was or was not enrolled + # (this will vary based on the course mode) + if enrollment_mode is not None: + actual_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertTrue(is_active) + self.assertEqual(actual_mode, enrollment_mode) + else: + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + def test_add_white_label_to_cart(self): + # Create a white label course (honor with a minimum price) + CourseModeFactory.create( + course_id=self.course.id, + mode_slug="honor", + mode_display_name="Honor", + min_price=100 + ) + + # Simulate the pipeline step for enrolling in this course + strategy = self._fake_strategy() + strategy.session_set('enroll_course_id', unicode(self.course.id)) + result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=E1111,E1124 + self.assertEqual(result, {}) + + # Expect that the uesr is NOT enrolled in the course + # because the user has not yet paid + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + # Expect that the course was added to the shopping cart + cart = Order.get_cart_for_user(self.user) + self.assertTrue(cart.has_items(PaidCourseRegistration)) + order_item = PaidCourseRegistration.objects.get(order=cart) + self.assertEqual(order_item.course_id, self.course.id) + + def test_auto_enroll_not_accessible(self): + # Set the course open date in the future + tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1) + self.course.enrollment_start = tomorrow + self.update_course(self.course, self.user.id) + + # Finish authentication and try to auto-enroll + # This should fail silently, with no exception + strategy = self._fake_strategy() + strategy.session_set('enroll_course_id', unicode(self.course.id)) + result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=E1111,E1124 + self.assertEqual(result, {}) + + # Verify that we were NOT enrolled + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + def test_no_course_id_skips_enroll(self): + strategy = self._fake_strategy() + result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=E1111,E1124 + self.assertEqual(result, {}) + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + def _fake_strategy(self): + """Simulate the strategy passed to the pipeline step. """ + request = RequestFactory().get(pipeline.get_complete_url(self.BACKEND_NAME)) + request.user = self.user + request.session = cache.SessionStore() + + return social_utils.load_strategy( + backend=self.BACKEND_NAME, request=request + ) diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index 6907cea26eb..f4f3600df76 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -30,8 +30,10 @@ class TestCase(unittest.TestCase): def setUp(self): super(TestCase, self).setUp() + self._original_providers = provider.Registry._get_all() provider.Registry._reset() def tearDown(self): provider.Registry._reset() + provider.Registry.configure_once(self._original_providers) super(TestCase, self).tearDown() diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index 49d4559d0fc..8adbc079b9f 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -6,9 +6,9 @@ <%block name="bodyclass">register verification-process step-select-track ${'is-upgrading' if upgrade else ''}</%block> <%block name="pagetitle"> % if upgrade: - ${_("Upgrade Your Registration for {} | Choose Your Track").format(course_name)} + ${_("Upgrade Your Enrollment for {} | Choose Your Track").format(course_name)} % else: - ${_("Register for {} | Choose Your Track").format(course_name)} + ${_("Enroll In {} | Choose Your Track").format(course_name)} %endif </%block> @@ -51,7 +51,7 @@ <div class=" msg msg-error"> <i class="msg-icon icon-warning-sign"></i> <div class="msg-content"> - <h3 class="title">${_("Sorry, there was an error when trying to register you")}</h3> + <h3 class="title">${_("Sorry, there was an error when trying to enroll you")}</h3> <div class="copy"> <p>${error}</p> </div> @@ -104,7 +104,7 @@ <ul class="list-actions"> <li class="action action-select"> % if upgrade: - <input type="submit" name="verified_mode" value="${_('Upgrade Your Registration')}" /> + <input type="submit" name="verified_mode" value="${_('Upgrade Your Enrollment')}" /> % else: <input type="submit" name="verified_mode" value="${_('Pursue a Verified Certificate')}" /> % endif diff --git a/lms/djangoapps/courseware/features/certificates.py b/lms/djangoapps/courseware/features/certificates.py index 5e5d0051ccd..3192d7999bc 100644 --- a/lms/djangoapps/courseware/features/certificates.py +++ b/lms/djangoapps/courseware/features/certificates.py @@ -69,7 +69,7 @@ def click_verified_track_button(): def select_verified_track_upgrade(step): select_contribution(32) world.wait_for_ajax_complete() - btn_css = 'input[value="Upgrade Your Registration"]' + btn_css = 'input[value="Upgrade Your Enrollment"]' world.css_click(btn_css) # TODO: might want to change this depending on the changes for upgrade assert world.is_css_present('section.progress') diff --git a/lms/envs/test.py b/lms/envs/test.py index 69389473030..8019cd1a4c6 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -203,6 +203,17 @@ simplefilter('ignore') # Change to "default" to see the first instance of each ######### Third-party auth ########## FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True +THIRD_PARTY_AUTH = { + "Google": { + "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", + "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test", + }, + "Facebook": { + "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", + "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test", + }, +} + ################################## OPENID ##################################### FEATURES['AUTH_USE_OPENID'] = True FEATURES['AUTH_USE_OPENID_PROVIDER'] = True diff --git a/lms/templates/login.html b/lms/templates/login.html index 6e4267f8367..29db663f65a 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -206,7 +206,7 @@ % for enabled in provider.Registry.enabled(): ## Translators: provider_name is the name of an external, third-party user authentication provider (like Google or LinkedIn). - <button type="submit" class="button button-primary button-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline.get_login_url(enabled.NAME, pipeline.AUTH_ENTRY_LOGIN)}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button> + <button type="submit" class="button button-primary button-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline_url[enabled.NAME]}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button> % endfor </div> diff --git a/lms/templates/register.html b/lms/templates/register.html index 13cb548f61e..0033c718391 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -124,7 +124,7 @@ % for enabled in provider.Registry.enabled(): ## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn). - <button type="submit" class="button button-primary button-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline.get_login_url(enabled.NAME, pipeline.AUTH_ENTRY_REGISTER)}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.NAME)}</button> + <button type="submit" class="button button-primary button-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.NAME]}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.NAME)}</button> % endfor </div> diff --git a/lms/templates/verify_student/_verification_header.html b/lms/templates/verify_student/_verification_header.html index 82671dd77ff..8a1cdadac22 100644 --- a/lms/templates/verify_student/_verification_header.html +++ b/lms/templates/verify_student/_verification_header.html @@ -4,13 +4,13 @@ <h2 class="title"> <span class="wrapper-sts"> % if upgrade: - <span class="sts-label">${_("You are upgrading your registration for")}</span> + <span class="sts-label">${_("You are upgrading your enrollment for")}</span> % elif reverify: <span class="sts-label">${_("You are re-verifying for")}</span> % elif modes_dict and "professional" in modes_dict: <span class="sts-label">${_("You are registering for")}</span> % else: - <span class="sts-label">${_("Congrats! You are now registered to audit")}</span> + <span class="sts-label">${_("Congrats! You are now enrolled in the audit track")}</span> % endif <span class="sts-course-org">${course_org}'s</span> <span class="sts-course-number">${course_num}</span> -- GitLab