Commit f9b5a917 by Will Daly

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.
parent 5a1df635
......@@ -183,6 +183,71 @@ class CourseMode(models.Model):
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):
"""
Returns the minimum price of the course in the appropriate currency over all the course's
......
......@@ -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", {})
......
"""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")
......
"""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)
......
"""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")
)
......@@ -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.
......
"""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')
......
......@@ -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>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment