Merge pull request #5745 from edx/will/third-party-auth-fixes
Third Party Auth Fixes
This commit is contained in:
0
common/djangoapps/course_modes/helpers.py
Normal file
0
common/djangoapps/course_modes/helpers.py
Normal file
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
113
common/djangoapps/student/helpers.py
Normal file
113
common/djangoapps/student/helpers.py
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
185
common/djangoapps/student/tests/test_login_registration_forms.py
Normal file
185
common/djangoapps/student/tests/test_login_registration_forms.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
return url
|
||||
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 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)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -191,13 +191,13 @@ class TestVerifyView(ModuleStoreTestCase):
|
||||
kwargs={"course_id": unicode(self.course_key)})
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertIn("You are now registered to audit", response.content)
|
||||
self.assertIn("You are now enrolled in the audit track", response.content)
|
||||
|
||||
def test_valid_course_upgrade_text(self):
|
||||
url = reverse('verify_student_verify',
|
||||
kwargs={"course_id": unicode(self.course_key)})
|
||||
response = self.client.get(url, {'upgrade': "True"})
|
||||
self.assertIn("You are upgrading your registration for", response.content)
|
||||
self.assertIn("You are upgrading your enrollment for", response.content)
|
||||
|
||||
def test_show_selected_contribution_amount(self):
|
||||
# Set the donation amount in the client's session
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user