Merge pull request #8707 from edx/rc/2015-06-29

Rc/2015 06 29
This commit is contained in:
Sarina Canelake
2015-07-02 11:08:11 -04:00
368 changed files with 13103 additions and 11403 deletions

4
.gitignore vendored
View File

@@ -33,6 +33,7 @@ codekit-config.json
*.mo
*.po
*.prob
*.dup
!django.po
!django.mo
!djangojs.po
@@ -91,6 +92,9 @@ logs
chromedriver.log
ghostdriver.log
### Celery artifacts ###
celerybeat-schedule
### Unknown artifacts
database.sqlite
courseware/static/js/mathjax/*

View File

@@ -19,6 +19,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
from models.settings.course_metadata import CourseMetadata
from xmodule.fields import Date
from xmodule.tabs import InvalidTabsException
from .utils import CourseTestCase
from xmodule.modulestore.django import modulestore
@@ -617,6 +618,7 @@ class CourseGradingTest(CourseTestCase):
self.assertEqual(json.loads(response.content).get('graderType'), u'notgraded')
@ddt.ddt
class CourseMetadataEditingTest(CourseTestCase):
"""
Tests for CourseMetadata.
@@ -626,6 +628,7 @@ class CourseMetadataEditingTest(CourseTestCase):
self.fullcourse = CourseFactory.create()
self.course_setting_url = get_url(self.course.id, 'advanced_settings_handler')
self.fullcourse_setting_url = get_url(self.fullcourse.id, 'advanced_settings_handler')
self.notes_tab = {"type": "notes", "name": "My Notes"}
def test_fetch_initial_fields(self):
test_model = CourseMetadata.fetch(self.course)
@@ -930,12 +933,11 @@ class CourseMetadataEditingTest(CourseTestCase):
"""
open_ended_tab = {"type": "open_ended", "name": "Open Ended Panel"}
peer_grading_tab = {"type": "peer_grading", "name": "Peer grading"}
notes_tab = {"type": "notes", "name": "My Notes"}
# First ensure that none of the tabs are visible
self.assertNotIn(open_ended_tab, self.course.tabs)
self.assertNotIn(peer_grading_tab, self.course.tabs)
self.assertNotIn(notes_tab, self.course.tabs)
self.assertNotIn(self.notes_tab, self.course.tabs)
# Now add the "combinedopenended" component and verify that the tab has been added
self.client.ajax_post(self.course_setting_url, {
@@ -944,7 +946,7 @@ class CourseMetadataEditingTest(CourseTestCase):
course = modulestore().get_course(self.course.id)
self.assertIn(open_ended_tab, course.tabs)
self.assertIn(peer_grading_tab, course.tabs)
self.assertNotIn(notes_tab, course.tabs)
self.assertNotIn(self.notes_tab, course.tabs)
# Now enable student notes and verify that the "My Notes" tab has also been added
self.client.ajax_post(self.course_setting_url, {
@@ -953,7 +955,7 @@ class CourseMetadataEditingTest(CourseTestCase):
course = modulestore().get_course(self.course.id)
self.assertIn(open_ended_tab, course.tabs)
self.assertIn(peer_grading_tab, course.tabs)
self.assertIn(notes_tab, course.tabs)
self.assertIn(self.notes_tab, course.tabs)
# Now remove the "combinedopenended" component and verify that the tab is gone
self.client.ajax_post(self.course_setting_url, {
@@ -962,7 +964,7 @@ class CourseMetadataEditingTest(CourseTestCase):
course = modulestore().get_course(self.course.id)
self.assertNotIn(open_ended_tab, course.tabs)
self.assertNotIn(peer_grading_tab, course.tabs)
self.assertIn(notes_tab, course.tabs)
self.assertIn(self.notes_tab, course.tabs)
# Finally disable student notes and verify that the "My Notes" tab is gone
self.client.ajax_post(self.course_setting_url, {
@@ -971,25 +973,40 @@ class CourseMetadataEditingTest(CourseTestCase):
course = modulestore().get_course(self.course.id)
self.assertNotIn(open_ended_tab, course.tabs)
self.assertNotIn(peer_grading_tab, course.tabs)
self.assertNotIn(notes_tab, course.tabs)
self.assertNotIn(self.notes_tab, course.tabs)
def mark_wiki_as_hidden(self, tabs):
""" Mark the wiki tab as hidden. """
for tab in tabs:
if tab.type == 'wiki':
tab['is_hidden'] = True
return tabs
def test_advanced_components_munge_tabs_validation_failure(self):
with patch('contentstore.views.course._refresh_course_tabs', side_effect=InvalidTabsException):
resp = self.client.ajax_post(self.course_setting_url, {
ADVANCED_COMPONENT_POLICY_KEY: {"value": ["notes"]}
})
self.assertEqual(resp.status_code, 400)
def test_advanced_components_munge_tabs_hidden_tabs(self):
updated_tabs = self.mark_wiki_as_hidden(self.course.tabs)
self.course.tabs = updated_tabs
error_msg = [
{
'message': 'An error occurred while trying to save your tabs',
'model': {'display_name': 'Tabs Exception'}
}
]
self.assertEqual(json.loads(resp.content), error_msg)
# verify that the course wasn't saved into the modulestore
course = modulestore().get_course(self.course.id)
self.assertNotIn("notes", course.advanced_modules)
@ddt.data(
[{'type': 'courseware'}, {'type': 'course_info'}, {'type': 'wiki', 'is_hidden': True}],
[{'type': 'courseware', 'name': 'Courses'}, {'type': 'course_info', 'name': 'Info'}],
)
def test_course_tab_configurations(self, tab_list):
self.course.tabs = tab_list
modulestore().update_item(self.course, self.user.id)
self.client.ajax_post(self.course_setting_url, {
ADVANCED_COMPONENT_POLICY_KEY: {"value": ["notes"]}
})
course = modulestore().get_course(self.course.id)
notes_tab = {"type": "notes", "name": "My Notes"}
self.assertIn(notes_tab, course.tabs)
tab_list.append(self.notes_tab)
self.assertEqual(tab_list, course.tabs)
class CourseGraderUpdatesTest(CourseTestCase):

View File

@@ -523,13 +523,13 @@ class TestLibraryAccess(SignalDisconnectTestMixin, LibraryTestCase):
self.client.logout()
self._assert_cannot_create_library(expected_code=302) # 302 redirect to login expected
# Now create a non-staff user with no permissions:
# Now check that logged-in users without CourseCreator role can still create libraries
self._login_as_non_staff_user(logout_first=False)
self.assertFalse(CourseCreatorRole().has_user(self.non_staff_user))
# Now check that logged-in users without any permissions cannot create libraries
with patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}):
self._assert_cannot_create_library()
lib_key2 = self._create_library(library="lib2", display_name="Test Library 2")
library2 = modulestore().get_library(lib_key2)
self.assertIsNotNone(library2)
@ddt.data(
CourseInstructorRole,

View File

@@ -282,8 +282,8 @@ def get_component_templates(courselike, library=False):
tab = 'common'
if template['metadata'].get('markdown') is None:
tab = 'advanced'
# Then the problem can override that with a tab: setting
tab = template['metadata'].get('tab', tab)
# Then the problem can override that with a tab: attribute (note: not nested in metadata)
tab = template.get('tab', tab)
templates_for_category.append(
create_template_dict(

View File

@@ -5,6 +5,7 @@ import copy
from django.shortcuts import redirect
import json
import random
import logging
import string # pylint: disable=deprecated-module
from django.utils.translation import ugettext as _
import django.utils
@@ -22,7 +23,7 @@ from xmodule.course_module import DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xmodule.tabs import CourseTab
from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException
from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
@@ -87,6 +88,8 @@ from util.milestones_helpers import (
is_valid_course_key
)
log = logging.getLogger(__name__)
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
'course_info_update_handler', 'course_search_index_handler',
'course_rerun_handler',
@@ -455,6 +458,7 @@ def course_listing(request):
'in_process_course_actions': in_process_course_actions,
'libraries_enabled': LIBRARIES_ENABLED,
'libraries': [format_library_for_view(lib) for lib in libraries],
'show_new_library_button': LIBRARIES_ENABLED and request.user.is_active,
'user': request.user,
'request_course_creator_url': reverse('contentstore.views.request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
@@ -1024,6 +1028,9 @@ def grading_handler(request, course_key_string, grader_index=None):
def _refresh_course_tabs(request, course_module):
"""
Automatically adds/removes tabs if changes to the course require them.
Raises:
InvalidTabsException: raised if there's a problem with the new version of the tabs.
"""
def update_tab(tabs, tab_type, tab_enabled):
@@ -1047,6 +1054,8 @@ def _refresh_course_tabs(request, course_module):
tab_enabled = tab_type.is_enabled(course_module, user=request.user)
update_tab(course_tabs, tab_type, tab_enabled)
CourseTabList.validate_tabs(course_tabs)
# Save the tabs into the course if they have been changed
if course_tabs != course_module.tabs:
course_module.tabs = course_tabs
@@ -1090,8 +1099,18 @@ def advanced_settings_handler(request, course_key_string):
)
if is_valid:
# update the course tabs if required by any setting changes
_refresh_course_tabs(request, course_module)
try:
# update the course tabs if required by any setting changes
_refresh_course_tabs(request, course_module)
except InvalidTabsException as err:
log.exception(err.message)
response_message = [
{
'message': _('An error occurred while trying to save your tabs'),
'model': {'display_name': _('Tabs Exception')}
}
]
return JsonResponseBadRequest(response_message)
# now update mongo
modulestore().update_item(course_module, request.user.id)
@@ -1101,7 +1120,7 @@ def advanced_settings_handler(request, course_key_string):
return JsonResponseBadRequest(errors)
# Handle all errors that validation doesn't catch
except (TypeError, ValueError) as err:
except (TypeError, ValueError, InvalidTabsException) as err:
return HttpResponseBadRequest(
django.utils.html.escape(err.message),
content_type="text/plain"

View File

@@ -30,7 +30,7 @@ from .component import get_component_templates, CONTAINER_TEMPLATES
from student.auth import (
STUDIO_VIEW_USERS, STUDIO_EDIT_ROLES, get_user_permissions, has_studio_read_access, has_studio_write_access
)
from student.roles import CourseCreatorRole, CourseInstructorRole, CourseStaffRole, LibraryUserRole
from student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole
from student import auth
from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest
@@ -115,9 +115,6 @@ def _create_library(request):
"""
Helper method for creating a new library.
"""
if not auth.has_access(request.user, CourseCreatorRole()):
log.exception(u"User %s tried to create a library without permission", request.user.username)
raise PermissionDenied()
display_name = None
try:
display_name = request.json['display_name']

View File

@@ -87,8 +87,8 @@ class UnitTestLibraries(ModuleStoreTestCase):
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True})
def test_lib_create_permission(self):
"""
Users who aren't given course creator roles shouldn't be able to create
libraries either.
Users who are not given course creator roles should still be able to
create libraries.
"""
self.client.logout()
ns_user, password = self.create_non_staff_user()
@@ -97,7 +97,7 @@ class UnitTestLibraries(ModuleStoreTestCase):
response = self.client.ajax_post(LIBRARY_REST_URL, {
'org': 'org', 'library': 'lib', 'display_name': "New Library",
})
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 200)
@ddt.data(
{},

View File

@@ -153,6 +153,12 @@ if ENV_TOKENS.get('SESSION_COOKIE_NAME', None):
# NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str()
SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME'))
# Set the names of cookies shared with the marketing site
# These have the same cookie domain as the session, which in production
# usually includes subdomains.
EDXMKTG_LOGGED_IN_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_LOGGED_IN_COOKIE_NAME', EDXMKTG_LOGGED_IN_COOKIE_NAME)
EDXMKTG_USER_INFO_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_USER_INFO_COOKIE_NAME', EDXMKTG_USER_INFO_COOKIE_NAME)
#Email overrides
DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL)
DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL)

View File

@@ -1,5 +1,13 @@
"""
Settings for bok choy tests
Settings for Bok Choy tests that are used for running CMS and LMS.
Bok Choy uses two different settings files:
1. test_static_optimized is used when invoking collectstatic
2. bok_choy is used when running CMS and LMS
Note: it isn't possible to have a single settings file, because Django doesn't
support both generating static assets to a directory and also serving static
from the same directory.
"""
import os
@@ -44,8 +52,20 @@ update_module_store_settings(
default_store=os.environ.get('DEFAULT_STORE', 'draft'),
)
# Enable django-pipeline and staticfiles
STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath()
############################ STATIC FILES #############################
# Enable debug so that static assets are served by Django
DEBUG = True
# Serve static files at /static directly from the staticfiles directory under test root
# Note: optimized files for testing are generated with settings from test_static_optimized
STATIC_URL = "/static/"
STATICFILES_FINDERS = (
'staticfiles.finders.FileSystemFinder',
)
STATICFILES_DIRS = (
(TEST_ROOT / "staticfiles").abspath(),
)
# Silence noisy logs
import logging
@@ -80,9 +100,6 @@ FEATURES['ENABLE_VIDEO_BUMPER'] = True # Enable video bumper in Studio settings
########################### Entrance Exams #################################
FEATURES['ENTRANCE_EXAMS'] = True
# Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True
# Point the URL used to test YouTube availability to our stub YouTube server
YOUTUBE_PORT = 9080
YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)

View File

@@ -42,6 +42,9 @@ from lms.envs.common import (
# technically accessible through the CMS via legacy URLs.
PROFILE_IMAGE_BACKEND, PROFILE_IMAGE_DEFAULT_FILENAME, PROFILE_IMAGE_DEFAULT_FILE_EXTENSION,
PROFILE_IMAGE_SECRET_KEY, PROFILE_IMAGE_MIN_BYTES, PROFILE_IMAGE_MAX_BYTES,
# The following setting is included as it is used to check whether to
# display credit eligibility table on the CMS or not.
ENABLE_CREDIT_ELIGIBILITY
)
from path import path
from warnings import simplefilter
@@ -174,7 +177,7 @@ FEATURES = {
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
# Enable credit eligibility feature
'ENABLE_CREDIT_ELIGIBILITY': False,
'ENABLE_CREDIT_ELIGIBILITY': ENABLE_CREDIT_ELIGIBILITY,
# Can the visibility of the discussion tab be configured on a per-course basis?
'ALLOW_HIDING_DISCUSSION_TAB': False,
@@ -248,7 +251,6 @@ from lms.envs.common import (
COURSE_KEY_PATTERN, COURSE_ID_PATTERN, USAGE_KEY_PATTERN, ASSET_KEY_PATTERN
)
######################### CSRF #########################################
# Forwards-compatibility with Django 1.7
@@ -306,7 +308,9 @@ MIDDLEWARE_CLASSES = (
'embargo.middleware.EmbargoMiddleware',
# Detects user-requested locale from 'accept-language' header in http request
'django.middleware.locale.LocaleMiddleware',
# TODO: Re-import the Django version once we upgrade to Django 1.8 [PLAT-671]
# 'django.middleware.locale.LocaleMiddleware',
'django_locale.middleware.LocaleMiddleware',
'django.middleware.transaction.TransactionMiddleware',
# needs to run after locale middleware (or anything that modifies the request context)
@@ -750,6 +754,7 @@ INSTALLED_APPS = (
# Additional problem types
'edx_jsme', # Molecular Structure
'openedx.core.djangoapps.content.course_overviews',
'openedx.core.djangoapps.content.course_structures',
# Credit courses
@@ -759,7 +764,10 @@ INSTALLED_APPS = (
################# EDX MARKETING SITE ##################################
EDXMKTG_COOKIE_NAME = 'edxloggedin'
EDXMKTG_LOGGED_IN_COOKIE_NAME = 'edxloggedin'
EDXMKTG_USER_INFO_COOKIE_NAME = 'edx-user-info'
EDXMKTG_USER_INFO_COOKIE_VERSION = 1
MKTG_URLS = {}
MKTG_URL_LINK_MAP = {

View File

@@ -175,7 +175,7 @@ CACHES = {
INSTALLED_APPS += ('external_auth', )
# Add milestones to Installed apps for testing
INSTALLED_APPS += ('milestones', )
INSTALLED_APPS += ('milestones', 'openedx.core.djangoapps.call_stack_manager')
# hide ratelimit warnings while running tests
filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit')

View File

@@ -0,0 +1,45 @@
"""
Settings used when generating static assets for use in tests.
For example, Bok Choy uses two different settings files:
1. test_static_optimized is used when invoking collectstatic
2. bok_choy is used when running CMS and LMS
Note: it isn't possible to have a single settings file, because Django doesn't
support both generating static assets to a directory and also serving static
from the same directory.
"""
import os
from path import path # pylint: disable=no-name-in-module
# Pylint gets confused by path.py instances, which report themselves as class
# objects. As a result, pylint applies the wrong regex in validating names,
# and throws spurious errors. Therefore, we disable invalid-name checking.
# pylint: disable=invalid-name
########################## Prod-like settings ###################################
# These should be as close as possible to the settings we use in production.
# As in prod, we read in environment and auth variables from JSON files.
# Unlike in prod, we use the JSON files stored in this repo.
# This is a convenience for ensuring (a) that we can consistently find the files
# and (b) that the files are the same in Jenkins as in local dev.
os.environ['SERVICE_VARIANT'] = 'bok_choy'
os.environ['CONFIG_ROOT'] = path(__file__).abspath().dirname() # pylint: disable=no-value-for-parameter
from .aws import * # pylint: disable=wildcard-import, unused-wildcard-import
######################### Testing overrides ####################################
# Redirects to the test_root folder within the repo
TEST_ROOT = CONFIG_ROOT.dirname().dirname() / "test_root" # pylint: disable=no-value-for-parameter
LOG_DIR = (TEST_ROOT / "log").abspath()
# Stores the static files under test root so that they don't overwrite existing static assets
STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath()
# Disables uglify when tests are running (used by build.js).
# 1. Uglify is by far the slowest part of the build process
# 2. Having full source code makes debugging tests easier for developers
os.environ['REQUIRE_BUILD_PROFILE_OPTIMIZE'] = 'none'

View File

@@ -19,6 +19,10 @@
}));
};
var jsOptimize = process.env.REQUIRE_BUILD_PROFILE_OPTIMIZE !== undefined ?
process.env.REQUIRE_BUILD_PROFILE_OPTIMIZE : 'uglify2';
return {
/**
* List the modules that will be optimized. All their immediate and deep
@@ -144,7 +148,7 @@
* mode to minify the code. Only available if REQUIRE_ENVIRONMENT is "rhino" (the default).
* - "none": No minification will be done.
*/
optimize: 'uglify2',
optimize: jsOptimize,
/**
* Sets the logging level. It is a number:
* TRACE: 0,

View File

@@ -141,6 +141,8 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
e.preventDefault();
$('.courses-tab').toggleClass('active', tab === 'courses');
$('.libraries-tab').toggleClass('active', tab === 'libraries');
// Also toggle this course-related notice shown below the course tab, if it is present:
$('.wrapper-creationrights').toggleClass('is-hidden', tab === 'libraries');
};
};

View File

@@ -102,7 +102,7 @@ var GradingView = ValidatingView.extend({
renderMinimumGradeCredit: function() {
var minimum_grade_credit = this.model.get('minimum_grade_credit');
this.$el.find('#course-minimum_grade_credit').val(
parseFloat(minimum_grade_credit) * 100 + '%'
Math.round(parseFloat(minimum_grade_credit) * 100) + '%'
);
},
setGracePeriod : function(event) {

View File

@@ -83,7 +83,6 @@
// platform Open edX logo and link
.footer-about-openedx {
@include float(right);
width: flex-grid(3,12);
@include text-align(right);
a {

View File

@@ -24,13 +24,13 @@
% if course_creator_status=='granted':
<a href="#" class="button new-button new-course-button"><i class="icon fa fa-plus icon-inline"></i>
${_("New Course")}</a>
% if libraries_enabled:
<a href="#" class="button new-button new-library-button"><i class="icon fa fa-plus icon-inline"></i>
${_("New Library")}</a>
% endif
% elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''):
<a href="mailto:${settings.FEATURES.get('STUDIO_REQUEST_EMAIL','')}">${_("Email staff to create course")}</a>
% endif
% if show_new_library_button:
<a href="#" class="button new-button new-library-button"><i class="icon fa fa-plus icon-inline"></i>
${_("New Library")}</a>
% endif
</li>
</ul>
</nav>
@@ -103,57 +103,57 @@
</form>
</div>
%if libraries_enabled:
<div class="wrapper-create-element wrapper-create-library">
<form class="form-create create-library library-info" id="create-library-form" name="create-library-form">
<div class="wrap-error">
<div id="library_creation_error" name="library_creation_error" class="message message-status message-status error" role="alert">
<p>${_("Please correct the highlighted fields below.")}</p>
</div>
% endif
%if libraries_enabled and show_new_library_button:
<div class="wrapper-create-element wrapper-create-library">
<form class="form-create create-library library-info" id="create-library-form" name="create-library-form">
<div class="wrap-error">
<div id="library_creation_error" name="library_creation_error" class="message message-status message-status error" role="alert">
<p>${_("Please correct the highlighted fields below.")}</p>
</div>
</div>
<div class="wrapper-form">
<h3 class="title">${_("Create a New Library")}</h3>
<div class="wrapper-form">
<h3 class="title">${_("Create a New Library")}</h3>
<fieldset>
<legend class="sr">${_("Required Information to Create a New Library")}</legend>
<fieldset>
<legend class="sr">${_("Required Information to Create a New Library")}</legend>
<ol class="list-input">
<li class="field text required" id="field-library-name">
<label for="new-library-name">${_("Library Name")}</label>
## Translators: This is an example name for a new content library, seen when filling out the form to create a new library. (A library is a collection of content or problems.)
<input class="new-library-name" id="new-library-name" type="text" name="new-library-name" required placeholder="${_('e.g. Computer Science Problems')}" aria-describedby="tip-new-library-name tip-error-new-library-name" />
<span class="tip" id="tip-new-library-name">${_("The public display name for your library.")}</span>
<span class="tip tip-error is-hiding" id="tip-error-new-library-name"></span>
</li>
<li class="field text required" id="field-organization">
<label for="new-library-org">${_("Organization")}</label>
<input class="new-library-org" id="new-library-org" type="text" name="new-library-org" required placeholder="${_('e.g. UniversityX or OrganizationX')}" aria-describedby="tip-new-library-org tip-error-new-library-org" />
<span class="tip" id="tip-new-library-org">${_("The public organization name for your library.")} ${_("This cannot be changed.")}</span>
<span class="tip tip-error is-hiding" id="tip-error-new-library-org"></span>
</li>
<ol class="list-input">
<li class="field text required" id="field-library-name">
<label for="new-library-name">${_("Library Name")}</label>
## Translators: This is an example name for a new content library, seen when filling out the form to create a new library. (A library is a collection of content or problems.)
<input class="new-library-name" id="new-library-name" type="text" name="new-library-name" required placeholder="${_('e.g. Computer Science Problems')}" aria-describedby="tip-new-library-name tip-error-new-library-name" />
<span class="tip" id="tip-new-library-name">${_("The public display name for your library.")}</span>
<span class="tip tip-error is-hiding" id="tip-error-new-library-name"></span>
</li>
<li class="field text required" id="field-organization">
<label for="new-library-org">${_("Organization")}</label>
<input class="new-library-org" id="new-library-org" type="text" name="new-library-org" required placeholder="${_('e.g. UniversityX or OrganizationX')}" aria-describedby="tip-new-library-org tip-error-new-library-org" />
<span class="tip" id="tip-new-library-org">${_("The public organization name for your library.")} ${_("This cannot be changed.")}</span>
<span class="tip tip-error is-hiding" id="tip-error-new-library-org"></span>
</li>
<li class="field text required" id="field-library-number">
<label for="new-library-number">${_("Library Code")}</label>
## Translators: This is an example for the "code" used to identify a library, seen when filling out the form to create a new library. This example is short for "Computer Science Problems". The example number may contain letters but must not contain spaces.
<input class="new-library-number" id="new-library-number" type="text" name="new-library-number" required placeholder="${_('e.g. CSPROB')}" aria-describedby="tip-new-library-number tip-error-new-library-number" />
<span class="tip" id="tip-new-library-number">${_("The unique code that identifies this library.")} <strong>${_("Note: This is part of your library URL, so no spaces or special characters are allowed.")}</strong> ${_("This cannot be changed.")}</span>
<span class="tip tip-error is-hiding" id="tip-error-new-library-number"></span>
</li>
</ol>
<li class="field text required" id="field-library-number">
<label for="new-library-number">${_("Library Code")}</label>
## Translators: This is an example for the "code" used to identify a library, seen when filling out the form to create a new library. This example is short for "Computer Science Problems". The example number may contain letters but must not contain spaces.
<input class="new-library-number" id="new-library-number" type="text" name="new-library-number" required placeholder="${_('e.g. CSPROB')}" aria-describedby="tip-new-library-number tip-error-new-library-number" />
<span class="tip" id="tip-new-library-number">${_("The unique code that identifies this library.")} <strong>${_("Note: This is part of your library URL, so no spaces or special characters are allowed.")}</strong> ${_("This cannot be changed.")}</span>
<span class="tip tip-error is-hiding" id="tip-error-new-library-number"></span>
</li>
</ol>
</fieldset>
</div>
<div class="actions">
<input type="hidden" value="${allow_unicode_course_id}" class="allow-unicode-course-id" />
<input type="submit" value="${_('Create')}" class="action action-primary new-library-save" />
<input type="button" value="${_('Cancel')}" class="action action-secondary action-cancel new-library-cancel" />
</div>
</form>
</div>
% endif
</fieldset>
</div>
<div class="actions">
<input type="hidden" value="${allow_unicode_course_id}" class="allow-unicode-course-id" />
<input type="submit" value="${_('Create')}" class="action action-primary new-library-save" />
<input type="button" value="${_('Cancel')}" class="action action-secondary action-cancel new-library-cancel" />
</div>
</form>
</div>
% endif
<!-- STATE: processing courses -->
@@ -449,7 +449,7 @@
</div>
</div>
</div>
%if course_creator_status == "granted":
% if show_new_library_button:
<div class="notice-item has-actions">
<div class="msg">
<h3 class="title">${_('Create Your First Library')}</h3>
@@ -464,7 +464,7 @@
</li>
</ul>
</div>
%endif
% endif
</div>
%endif

View File

@@ -3,6 +3,9 @@
<li class="current">
<a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a>
</li>
<li>
<a class="link-tab" href="#tab2"><%= gettext("Common Problems with Hints and Feedback") %></a>
</li>
<li>
<a class="link-tab" href="#tab3"><%= gettext("Advanced") %></a>
</li>

View File

@@ -130,8 +130,9 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
<ol class="list-input">
% if 'grade' in credit_requirements:
<li class="field text is-not-editable" id="credit-minimum-passing-grade">
<label for="minimum-passing-grade">${_("Minimum Passing Grade")}</label>
<label>${_("Minimum Passing Grade")}</label>
% for requirement in credit_requirements['grade']:
<label for="${requirement['name']}" class="sr">${_("Minimum Passing Grade")}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="${requirement['name']}" value="${'{0:.0f}%'.format(float(requirement['criteria']['min_grade'] or 0)*100)}" readonly />
% endfor
@@ -140,8 +141,9 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
% if 'proctored_exam' in credit_requirements:
<li class="field text is-not-editable" id="credit-proctoring-requirements">
<label for="proctoring-requirements">${_("Successful Proctored Exam")}</label>
<label>${_("Successful Proctored Exam")}</label>
% for requirement in credit_requirements['proctored_exam']:
<label for="${requirement['name']}" class="sr">${_('Proctored Exam {number}').format(number=loop.index+1)}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="${requirement['name']}" value="${requirement['display_name']}" readonly />
% endfor
@@ -150,9 +152,10 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
% if 'reverification' in credit_requirements:
<li class="field text is-not-editable" id="credit-reverification-requirements">
<label for="reverification-requirements">${_("Successful In Course Reverification")}</label>
<label>${_("Successful In-Course Reverification")}</label>
% for requirement in credit_requirements['reverification']:
## Translators: 'Access to Assessment 1' means the access for a requirement with name 'Assessment 1'
<label for="${requirement['name']}" class="sr">${_('In-Course Reverification {number}').format(number=loop.index+1)}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="${requirement['name']}" value="${_('Access to {display_name}').format(display_name=requirement['display_name'])}" readonly />
% endfor

View File

@@ -85,8 +85,8 @@
<ol class="list-input">
<li class="field text" id="field-course-minimum_grade_credit">
<label for="course-minimum_grade_credit">${_("Minimum Passing Grade to Earn Credit:")}</label>
<input type="text" class="short time" id="course-minimum_grade_credit" value="0" placeholder="80%" autocomplete="off" />
<span class="tip tip-inline">${_("Must be greater than or equal to passing grade")}</span>
<input type="text" class="short time" id="course-minimum_grade_credit" value="0" placeholder="80%" autocomplete="off" aria-describedby="minimum_grade_description"/>
<span class="tip tip-inline" id="minimum_grade_description">${_("Must be greater than or equal to passing grade")}</span>
</li>
</ol>
</section>

View File

@@ -351,7 +351,7 @@
</p>
<p>What Apple device competed with the portable CD player?</p>
<span><form class="choicegroup capa_inputtype" id="inputtype_i4x-AndyA-ABT101-problem-46d2b65d793549e2876729d55df9a2cb_2_1">
<div class="indicator_container">
<div class="indicator-container">
<span class="unanswered" style="display:inline-block;" id="status_i4x-AndyA-ABT101-problem-46d2b65d793549e2876729d55df9a2cb_2_1"></span>
</div>

View File

@@ -119,7 +119,6 @@ class ChooseModeView(View):
"course_num": course.display_number_with_default,
"chosen_price": chosen_price,
"error": error,
"can_audit": "audit" in modes,
"responsive": True
}
if "verified" in modes:

View File

@@ -17,3 +17,6 @@ Run migrations to install the configuration table.
Use the admin site to add a new ``DarkLangConfig`` that is enabled, and lists the
languages that should be released.
"""
# this is the UserPreference key for the currently-active dark language, if any
DARK_LANGUAGE_KEY = 'dark-lang'

View File

@@ -12,9 +12,17 @@ the SessionMiddleware.
"""
from django.conf import settings
from django.utils.translation.trans_real import parse_accept_lang_header
from dark_lang import DARK_LANGUAGE_KEY
from dark_lang.models import DarkLangConfig
from openedx.core.djangoapps.user_api.preferences.api import (
delete_user_preference, get_user_preference, set_user_preference
)
from lang_pref import LANGUAGE_KEY
# TODO re-import this once we're on Django 1.5 or greater. [PLAT-671]
# from django.utils.translation.trans_real import parse_accept_lang_header
# from django.utils.translation import LANGUAGE_SESSION_KEY
from django_locale.trans_real import parse_accept_lang_header, LANGUAGE_SESSION_KEY
def dark_parse_accept_lang_header(accept):
@@ -81,11 +89,17 @@ class DarkLangMiddleware(object):
self._clean_accept_headers(request)
self._activate_preview_language(request)
def _is_released(self, lang_code):
"""
``True`` iff one of the values in ``self.released_langs`` is a prefix of ``lang_code``.
"""
return any(lang_code.lower().startswith(released_lang.lower()) for released_lang in self.released_langs)
def _fuzzy_match(self, lang_code):
"""Returns a fuzzy match for lang_code"""
if lang_code in self.released_langs:
return lang_code
lang_prefix = lang_code.partition('-')[0]
for released_lang in self.released_langs:
released_prefix = released_lang.partition('-')[0]
if lang_prefix == released_prefix:
return released_lang
return None
def _format_accept_value(self, lang, priority=1.0):
"""
@@ -102,12 +116,13 @@ class DarkLangMiddleware(object):
if accept is None or accept == '*':
return
new_accept = ", ".join(
self._format_accept_value(lang, priority)
for lang, priority
in dark_parse_accept_lang_header(accept)
if self._is_released(lang)
)
new_accept = []
for lang, priority in dark_parse_accept_lang_header(accept):
fuzzy_code = self._fuzzy_match(lang.lower())
if fuzzy_code:
new_accept.append(self._format_accept_value(fuzzy_code, priority))
new_accept = ", ".join(new_accept)
request.META['HTTP_ACCEPT_LANGUAGE'] = new_accept
@@ -115,15 +130,38 @@ class DarkLangMiddleware(object):
"""
If the request has the get parameter ``preview-lang``,
and that language doesn't appear in ``self.released_langs``,
then set the session ``django_language`` to that language.
then set the session LANGUAGE_SESSION_KEY to that language.
"""
auth_user = request.user.is_authenticated()
if 'clear-lang' in request.GET:
if 'django_language' in request.session:
del request.session['django_language']
# delete the session language key (if one is set)
if LANGUAGE_SESSION_KEY in request.session:
del request.session[LANGUAGE_SESSION_KEY]
if auth_user:
# Reset user's dark lang preference to null
delete_user_preference(request.user, DARK_LANGUAGE_KEY)
# Get & set user's preferred language
user_pref = get_user_preference(request.user, LANGUAGE_KEY)
if user_pref:
request.session[LANGUAGE_SESSION_KEY] = user_pref
return
# Get the user's preview lang - this is either going to be set from a query
# param `?preview-lang=xx`, or we may have one already set as a dark lang preference.
preview_lang = request.GET.get('preview-lang', None)
if not preview_lang and auth_user:
# Get the request user's dark lang preference
preview_lang = get_user_preference(request.user, DARK_LANGUAGE_KEY)
# User doesn't have a dark lang preference, so just return
if not preview_lang:
return
request.session['django_language'] = preview_lang
# Set the session key to the requested preview lang
request.session[LANGUAGE_SESSION_KEY] = preview_lang
# Make sure that we set the requested preview lang as the dark lang preference for the
# user, so that the lang_pref middleware doesn't clobber away the dark lang preview.
if auth_user:
set_user_preference(request.user, DARK_LANGUAGE_KEY, preview_lang)

View File

@@ -25,7 +25,7 @@ class DarkLangConfig(ConfigurationModel):
if not self.released_languages.strip(): # pylint: disable=no-member
return []
languages = [lang.strip() for lang in self.released_languages.split(',')] # pylint: disable=no-member
languages = [lang.lower().strip() for lang in self.released_languages.split(',')] # pylint: disable=no-member
# Put in alphabetical order
languages.sort()
return languages

View File

@@ -4,11 +4,17 @@ Tests of DarkLangMiddleware
from django.contrib.auth.models import User
from django.http import HttpRequest
import ddt
from django.test import TestCase
from mock import Mock
import unittest
from dark_lang.middleware import DarkLangMiddleware
from dark_lang.models import DarkLangConfig
# TODO PLAT-671 Import from Django 1.8
# from django.utils.translation import LANGUAGE_SESSION_KEY
from django_locale.trans_real import LANGUAGE_SESSION_KEY
from student.tests.factories import UserFactory
UNSET = object()
@@ -23,6 +29,7 @@ def set_if_set(dct, key, value):
dct[key] = value
@ddt.ddt
class DarkLangMiddlewareTests(TestCase):
"""
Tests of DarkLangMiddleware
@@ -37,18 +44,18 @@ class DarkLangMiddlewareTests(TestCase):
enabled=True
).save()
def process_request(self, django_language=UNSET, accept=UNSET, preview_lang=UNSET, clear_lang=UNSET):
def process_request(self, language_session_key=UNSET, accept=UNSET, preview_lang=UNSET, clear_lang=UNSET):
"""
Build a request and then process it using the ``DarkLangMiddleware``.
Args:
django_language (str): The language code to set in request.session['django_language']
language_session_key (str): The language code to set in request.session[LANUGAGE_SESSION_KEY]
accept (str): The accept header to set in request.META['HTTP_ACCEPT_LANGUAGE']
preview_lang (str): The value to set in request.GET['preview_lang']
clear_lang (str): The value to set in request.GET['clear_lang']
"""
session = {}
set_if_set(session, 'django_language', django_language)
set_if_set(session, LANGUAGE_SESSION_KEY, language_session_key)
meta = {}
set_if_set(meta, 'HTTP_ACCEPT_LANGUAGE', accept)
@@ -61,7 +68,8 @@ class DarkLangMiddlewareTests(TestCase):
spec=HttpRequest,
session=session,
META=meta,
GET=get
GET=get,
user=UserFactory()
)
self.assertIsNone(DarkLangMiddleware().process_request(request))
return request
@@ -82,6 +90,10 @@ class DarkLangMiddlewareTests(TestCase):
def test_wildcard_accept(self):
self.assertAcceptEquals('*', self.process_request(accept='*'))
def test_malformed_accept(self):
self.assertAcceptEquals('', self.process_request(accept='xxxxxxxxxxxx'))
self.assertAcceptEquals('', self.process_request(accept='en;q=1.0, es-419:q-0.8'))
def test_released_accept(self):
self.assertAcceptEquals(
'rel;q=1.0',
@@ -123,14 +135,17 @@ class DarkLangMiddlewareTests(TestCase):
)
def test_accept_released_territory(self):
# We will munge 'rel-ter' to be 'rel', so the 'rel-ter'
# user will actually receive the released language 'rel'
# (Otherwise, the user will actually end up getting the server default)
self.assertAcceptEquals(
'rel-ter;q=1.0, rel;q=0.5',
'rel;q=1.0, rel;q=0.5',
self.process_request(accept='rel-ter;q=1.0, rel;q=0.5')
)
def test_accept_mixed_case(self):
self.assertAcceptEquals(
'rel-TER;q=1.0, REL;q=0.5',
'rel;q=1.0, rel;q=0.5',
self.process_request(accept='rel-TER;q=1.0, REL;q=0.5')
)
@@ -140,18 +155,92 @@ class DarkLangMiddlewareTests(TestCase):
enabled=True
).save()
# Since we have only released "rel-ter", the requested code "rel" will
# fuzzy match to "rel-ter", in addition to "rel-ter" exact matching "rel-ter"
self.assertAcceptEquals(
'rel-ter;q=1.0',
'rel-ter;q=1.0, rel-ter;q=0.5',
self.process_request(accept='rel-ter;q=1.0, rel;q=0.5')
)
@ddt.data(
('es;q=1.0, pt;q=0.5', 'es-419;q=1.0'), # 'es' should get 'es-419', not English
('es-AR;q=1.0, pt;q=0.5', 'es-419;q=1.0'), # 'es-AR' should get 'es-419', not English
)
@ddt.unpack
def test_partial_match_es419(self, accept_header, expected):
# Release es-419
DarkLangConfig(
released_languages=('es-419, en'),
changed_by=self.user,
enabled=True
).save()
self.assertAcceptEquals(
expected,
self.process_request(accept=accept_header)
)
def test_partial_match_esar_es(self):
# If I release 'es', 'es-AR' should get 'es', not English
DarkLangConfig(
released_languages=('es, en'),
changed_by=self.user,
enabled=True
).save()
self.assertAcceptEquals(
'es;q=1.0',
self.process_request(accept='es-AR;q=1.0, pt;q=0.5')
)
@ddt.data(
# Test condition: If I release 'es-419, es, es-es'...
('es;q=1.0, pt;q=0.5', 'es;q=1.0'), # 1. es should get es
('es-419;q=1.0, pt;q=0.5', 'es-419;q=1.0'), # 2. es-419 should get es-419
('es-es;q=1.0, pt;q=0.5', 'es-es;q=1.0'), # 3. es-es should get es-es
)
@ddt.unpack
def test_exact_match_gets_priority(self, accept_header, expected):
# Release 'es-419, es, es-es'
DarkLangConfig(
released_languages=('es-419, es, es-es'),
changed_by=self.user,
enabled=True
).save()
self.assertAcceptEquals(
expected,
self.process_request(accept=accept_header)
)
@unittest.skip("This won't work until fallback is implemented for LA country codes. See LOC-86")
@ddt.data(
'es-AR', # Argentina
'es-PY', # Paraguay
)
def test_partial_match_es_la(self, latin_america_code):
# We need to figure out the best way to implement this. There are a ton of LA country
# codes that ought to fall back to 'es-419' rather than 'es-es'.
# http://unstats.un.org/unsd/methods/m49/m49regin.htm#americas
# If I release 'es, es-419'
# Latin American codes should get es-419
DarkLangConfig(
released_languages=('es, es-419'),
changed_by=self.user,
enabled=True
).save()
self.assertAcceptEquals(
'es-419;q=1.0',
self.process_request(accept='{};q=1.0, pt;q=0.5'.format(latin_america_code))
)
def assertSessionLangEquals(self, value, request):
"""
Assert that the 'django_language' set in request.session is equal to value
Assert that the LANGUAGE_SESSION_KEY set in request.session is equal to value
"""
self.assertEquals(
value,
request.session.get('django_language', UNSET)
request.session.get(LANGUAGE_SESSION_KEY, UNSET)
)
def test_preview_lang_with_released_language(self):
@@ -163,7 +252,7 @@ class DarkLangMiddlewareTests(TestCase):
self.assertSessionLangEquals(
'rel',
self.process_request(preview_lang='rel', django_language='notrel')
self.process_request(preview_lang='rel', language_session_key='notrel')
)
def test_preview_lang_with_dark_language(self):
@@ -174,7 +263,7 @@ class DarkLangMiddlewareTests(TestCase):
self.assertSessionLangEquals(
'unrel',
self.process_request(preview_lang='unrel', django_language='notrel')
self.process_request(preview_lang='unrel', language_session_key='notrel')
)
def test_clear_lang(self):
@@ -185,12 +274,12 @@ class DarkLangMiddlewareTests(TestCase):
self.assertSessionLangEquals(
UNSET,
self.process_request(clear_lang=True, django_language='rel')
self.process_request(clear_lang=True, language_session_key='rel')
)
self.assertSessionLangEquals(
UNSET,
self.process_request(clear_lang=True, django_language='unrel')
self.process_request(clear_lang=True, language_session_key='unrel')
)
def test_disabled(self):
@@ -203,17 +292,17 @@ class DarkLangMiddlewareTests(TestCase):
self.assertSessionLangEquals(
'rel',
self.process_request(clear_lang=True, django_language='rel')
self.process_request(clear_lang=True, language_session_key='rel')
)
self.assertSessionLangEquals(
'unrel',
self.process_request(clear_lang=True, django_language='unrel')
self.process_request(clear_lang=True, language_session_key='unrel')
)
self.assertSessionLangEquals(
'rel',
self.process_request(preview_lang='unrel', django_language='rel')
self.process_request(preview_lang='unrel', language_session_key='rel')
)
def test_accept_chinese_language_codes(self):
@@ -224,6 +313,6 @@ class DarkLangMiddlewareTests(TestCase):
).save()
self.assertAcceptEquals(
'zh-CN;q=1.0, zh-TW;q=0.5, zh-HK;q=0.3',
'zh-cn;q=1.0, zh-tw;q=0.5, zh-hk;q=0.3',
self.process_request(accept='zh-Hans;q=1.0, zh-Hant-TW;q=0.5, zh-HK;q=0.3')
)

View File

@@ -0,0 +1,7 @@
"""
TODO: This module is imported from the stable Django 1.8 branch, as a
copy of https://github.com/django/django/blob/stable/1.8.x/django/middleware/locale.py.
Remove this file and re-import this middleware from Django once the
codebase is upgraded with a modern version of Django. [PLAT-671]
"""

View File

@@ -0,0 +1,83 @@
# TODO: This file is imported from the stable Django 1.8 branch. Remove this file
# and re-import this middleware from Django once the codebase is upgraded. [PLAT-671]
# pylint: disable=invalid-name, missing-docstring
"This is the locale selecting middleware that will look at accept headers"
from django.conf import settings
from django.core.urlresolvers import (
LocaleRegexURLResolver, get_resolver, get_script_prefix, is_valid_path,
)
from django.http import HttpResponseRedirect
from django.utils import translation
from django.utils.cache import patch_vary_headers
# Override the Django 1.4 implementation with the 1.8 implementation
from django_locale.trans_real import get_language_from_request
class LocaleMiddleware(object):
"""
This is a very simple middleware that parses a request
and decides what translation object to install in the current
thread context. This allows pages to be dynamically
translated to the language the user desires (if the language
is available, of course).
"""
response_redirect_class = HttpResponseRedirect
def __init__(self):
self._is_language_prefix_patterns_used = False
for url_pattern in get_resolver(None).url_patterns:
if isinstance(url_pattern, LocaleRegexURLResolver):
self._is_language_prefix_patterns_used = True
break
def process_request(self, request):
check_path = self.is_language_prefix_patterns_used()
# This call is broken in Django 1.4:
# https://github.com/django/django/blob/stable/1.4.x/django/utils/translation/trans_real.py#L399
# (we override parse_accept_lang_header to a fixed version in dark_lang.middleware)
language = get_language_from_request(
request, check_path=check_path)
translation.activate(language)
request.LANGUAGE_CODE = translation.get_language()
def process_response(self, request, response):
language = translation.get_language()
language_from_path = translation.get_language_from_path(request.path_info)
if (response.status_code == 404 and not language_from_path
and self.is_language_prefix_patterns_used()):
urlconf = getattr(request, 'urlconf', None)
language_path = '/%s%s' % (language, request.path_info)
path_valid = is_valid_path(language_path, urlconf)
if (not path_valid and settings.APPEND_SLASH
and not language_path.endswith('/')):
path_valid = is_valid_path("%s/" % language_path, urlconf)
if path_valid:
script_prefix = get_script_prefix()
language_url = "%s://%s%s" % (
request.scheme,
request.get_host(),
# insert language after the script prefix and before the
# rest of the URL
request.get_full_path().replace(
script_prefix,
'%s%s/' % (script_prefix, language),
1
)
)
return self.response_redirect_class(language_url)
if not (self.is_language_prefix_patterns_used()
and language_from_path):
patch_vary_headers(response, ('Accept-Language',))
if 'Content-Language' not in response:
response['Content-Language'] = language
return response
def is_language_prefix_patterns_used(self):
"""
Returns `True` if the `LocaleRegexURLResolver` is used
at root level of the urlpatterns, else it returns `False`.
"""
return self._is_language_prefix_patterns_used

View File

@@ -0,0 +1,157 @@
# pylint: disable=invalid-name, line-too-long, super-method-not-called
"""
Tests taken from Django upstream:
https://github.com/django/django/blob/e6b34193c5c7d117ededdab04bb16caf8864f07c/tests/regressiontests/i18n/tests.py
"""
from django.conf import settings
from django.test import TestCase, RequestFactory
from django_locale.trans_real import (
parse_accept_lang_header, get_language_from_request, LANGUAGE_SESSION_KEY
)
# Added to test middleware around dark lang
from django.contrib.auth.models import User
from django.test.utils import override_settings
from dark_lang.models import DarkLangConfig
# Adding to support test differences between Django and our own settings
@override_settings(LANGUAGES=[
('pt', 'Portuguese'),
('pt-br', 'Portuguese-Brasil'),
('es', 'Spanish'),
('es-ar', 'Spanish (Argentina)'),
('de', 'Deutch'),
('zh-cn', 'Chinese (China)'),
('ar-sa', 'Arabic (Saudi Arabia)'),
])
class MiscTests(TestCase):
"""
Tests taken from Django upstream:
https://github.com/django/django/blob/e6b34193c5c7d117ededdab04bb16caf8864f07c/tests/regressiontests/i18n/tests.py
"""
def setUp(self):
self.rf = RequestFactory()
# Added to test middleware around dark lang
user = User()
user.save()
DarkLangConfig(
released_languages='pt, pt-br, es, de, es-ar, zh-cn, ar-sa',
changed_by=user,
enabled=True
).save()
def test_parse_spec_http_header(self):
"""
Testing HTTP header parsing. First, we test that we can parse the
values according to the spec (and that we extract all the pieces in
the right order).
"""
p = parse_accept_lang_header
# Good headers.
self.assertEqual([('de', 1.0)], p('de'))
self.assertEqual([('en-AU', 1.0)], p('en-AU'))
self.assertEqual([('es-419', 1.0)], p('es-419'))
self.assertEqual([('*', 1.0)], p('*;q=1.00'))
self.assertEqual([('en-AU', 0.123)], p('en-AU;q=0.123'))
self.assertEqual([('en-au', 0.5)], p('en-au;q=0.5'))
self.assertEqual([('en-au', 1.0)], p('en-au;q=1.0'))
self.assertEqual([('da', 1.0), ('en', 0.5), ('en-gb', 0.25)], p('da, en-gb;q=0.25, en;q=0.5'))
self.assertEqual([('en-au-xx', 1.0)], p('en-au-xx'))
self.assertEqual([('de', 1.0), ('en-au', 0.75), ('en-us', 0.5), ('en', 0.25), ('es', 0.125), ('fa', 0.125)], p('de,en-au;q=0.75,en-us;q=0.5,en;q=0.25,es;q=0.125,fa;q=0.125'))
self.assertEqual([('*', 1.0)], p('*'))
self.assertEqual([('de', 1.0)], p('de;q=0.'))
self.assertEqual([('en', 1.0), ('*', 0.5)], p('en; q=1.0, * ; q=0.5'))
self.assertEqual([], p(''))
# Bad headers; should always return [].
self.assertEqual([], p('en-gb;q=1.0000'))
self.assertEqual([], p('en;q=0.1234'))
self.assertEqual([], p('en;q=.2'))
self.assertEqual([], p('abcdefghi-au'))
self.assertEqual([], p('**'))
self.assertEqual([], p('en,,gb'))
self.assertEqual([], p('en-au;q=0.1.0'))
self.assertEqual([], p('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXZ,en'))
self.assertEqual([], p('da, en-gb;q=0.8, en;q=0.7,#'))
self.assertEqual([], p('de;q=2.0'))
self.assertEqual([], p('de;q=0.a'))
self.assertEqual([], p('12-345'))
self.assertEqual([], p(''))
def test_parse_literal_http_header(self):
"""
Now test that we parse a literal HTTP header correctly.
"""
g = get_language_from_request
r = self.rf.get('/')
r.COOKIES = {}
r.META = {'HTTP_ACCEPT_LANGUAGE': 'pt-br'}
self.assertEqual('pt-br', g(r))
r.META = {'HTTP_ACCEPT_LANGUAGE': 'pt'}
self.assertEqual('pt', g(r))
r.META = {'HTTP_ACCEPT_LANGUAGE': 'es,de'}
self.assertEqual('es', g(r))
r.META = {'HTTP_ACCEPT_LANGUAGE': 'es-ar,de'}
self.assertEqual('es-ar', g(r))
# This test assumes there won't be a Django translation to a US
# variation of the Spanish language, a safe assumption. When the
# user sets it as the preferred language, the main 'es'
# translation should be selected instead.
r.META = {'HTTP_ACCEPT_LANGUAGE': 'es-us'}
self.assertEqual(g(r), 'es')
# This tests the following scenario: there isn't a main language (zh)
# translation of Django but there is a translation to variation (zh_CN)
# the user sets zh-cn as the preferred language, it should be selected
# by Django without falling back nor ignoring it.
r.META = {'HTTP_ACCEPT_LANGUAGE': 'zh-cn,de'}
self.assertEqual(g(r), 'zh-cn')
def test_logic_masked_by_darklang(self):
g = get_language_from_request
r = self.rf.get('/')
r.COOKIES = {}
r.META = {'HTTP_ACCEPT_LANGUAGE': 'ar-qa'}
self.assertEqual('ar-sa', g(r))
r.session = {LANGUAGE_SESSION_KEY: 'es'}
self.assertEqual('es', g(r))
def test_parse_language_cookie(self):
"""
Now test that we parse language preferences stored in a cookie correctly.
"""
g = get_language_from_request
r = self.rf.get('/')
r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'pt-br'}
r.META = {}
self.assertEqual('pt-br', g(r))
r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'pt'}
r.META = {}
self.assertEqual('pt', g(r))
r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'es'}
r.META = {'HTTP_ACCEPT_LANGUAGE': 'de'}
self.assertEqual('es', g(r))
# This test assumes there won't be a Django translation to a US
# variation of the Spanish language, a safe assumption. When the
# user sets it as the preferred language, the main 'es'
# translation should be selected instead.
r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'es-us'}
r.META = {}
self.assertEqual(g(r), 'es')
# This tests the following scenario: there isn't a main language (zh)
# translation of Django but there is a translation to variation (zh_CN)
# the user sets zh-cn as the preferred language, it should be selected
# by Django without falling back nor ignoring it.
r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'zh-cn'}
r.META = {'HTTP_ACCEPT_LANGUAGE': 'de'}
self.assertEqual(g(r), 'zh-cn')

View File

@@ -0,0 +1,131 @@
"""Translation helper functions."""
# Imported from Django 1.8
# pylint: disable=invalid-name
import re
from django.conf import settings
from django.conf.locale import LANG_INFO
from django.utils import translation
# Format of Accept-Language header values. From RFC 2616, section 14.4 and 3.9.
# and RFC 3066, section 2.1
accept_language_re = re.compile(r'''
([A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*|\*) # "en", "en-au", "x-y-z", "*"
(?:\s*;\s*q=(0(?:\.\d{,3})?|1(?:.0{,3})?))? # Optional "q=1.00", "q=0.8"
(?:\s*,\s*|$) # Multiple accepts per header.
''', re.VERBOSE)
language_code_re = re.compile(r'^[a-z]{1,8}(?:-[a-z0-9]{1,8})*$', re.IGNORECASE)
LANGUAGE_SESSION_KEY = '_language'
def parse_accept_lang_header(lang_string):
"""
Parses the lang_string, which is the body of an HTTP Accept-Language
header, and returns a list of (lang, q-value), ordered by 'q' values.
Any format errors in lang_string results in an empty list being returned.
"""
# parse_accept_lang_header is broken until we are on Django 1.5 or greater
# See https://code.djangoproject.com/ticket/19381
result = []
pieces = accept_language_re.split(lang_string)
if pieces[-1]:
return []
for i in range(0, len(pieces) - 1, 3):
first, lang, priority = pieces[i: i + 3]
if first:
return []
priority = priority and float(priority) or 1.0
result.append((lang, priority))
result.sort(key=lambda k: k[1], reverse=True)
return result
def get_supported_language_variant(lang_code, strict=False):
"""
Returns the language-code that's listed in supported languages, possibly
selecting a more generic variant. Raises LookupError if nothing found.
If `strict` is False (the default), the function will look for an alternative
country-specific variant when the currently checked is not found.
lru_cache should have a maxsize to prevent from memory exhaustion attacks,
as the provided language codes are taken from the HTTP request. See also
<https://www.djangoproject.com/weblog/2007/oct/26/security-fix/>.
"""
if lang_code:
# If 'fr-ca' is not supported, try special fallback or language-only 'fr'.
possible_lang_codes = [lang_code]
try:
# TODO skip this, or import updated LANG_INFO format from __future__
# (fallback option wasn't added until
# https://github.com/django/django/commit/5dcdbe95c749d36072f527e120a8cb463199ae0d)
possible_lang_codes.extend(LANG_INFO[lang_code]['fallback'])
except KeyError:
pass
generic_lang_code = lang_code.split('-')[0]
possible_lang_codes.append(generic_lang_code)
supported_lang_codes = dict(settings.LANGUAGES)
for code in possible_lang_codes:
# Note: django 1.4 implementation of check_for_language is OK to use
if code in supported_lang_codes and translation.check_for_language(code):
return code
if not strict:
# if fr-fr is not supported, try fr-ca.
for supported_code in supported_lang_codes:
if supported_code.startswith(generic_lang_code + '-'):
return supported_code
raise LookupError(lang_code)
def get_language_from_request(request, check_path=False):
"""
Analyzes the request to find what language the user wants the system to
show. Only languages listed in settings.LANGUAGES are taken into account.
If the user requests a sublanguage where we have a main language, we send
out the main language.
If check_path is True, the URL path prefix will be checked for a language
code, otherwise this is skipped for backwards compatibility.
"""
if check_path:
# Note: django 1.4 implementation of get_language_from_path is OK to use
lang_code = translation.get_language_from_path(request.path_info)
if lang_code is not None:
return lang_code
supported_lang_codes = dict(settings.LANGUAGES)
if hasattr(request, 'session'):
lang_code = request.session.get(LANGUAGE_SESSION_KEY)
# Note: django 1.4 implementation of check_for_language is OK to use
if lang_code in supported_lang_codes and lang_code is not None and translation.check_for_language(lang_code):
return lang_code
lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
try:
return get_supported_language_variant(lang_code)
except LookupError:
pass
accept = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
# broken in 1.4, so defined above
for accept_lang, unused in parse_accept_lang_header(accept):
if accept_lang == '*':
break
if not language_code_re.search(accept_lang):
continue
try:
return get_supported_language_variant(accept_lang)
except LookupError:
continue
try:
return get_supported_language_variant(settings.LANGUAGE_CODE)
except LookupError:
return settings.LANGUAGE_CODE

View File

@@ -10,6 +10,9 @@ import pygeoip
from django.core.cache import cache
from django.conf import settings
from rest_framework.response import Response
from rest_framework import status
from ipware.ip import get_ip
from embargo.models import CountryAccessRule, RestrictedCourse
@@ -166,3 +169,30 @@ def _country_code_from_ip(ip_addr):
return pygeoip.GeoIP(settings.GEOIPV6_PATH).country_code_by_addr(ip_addr)
else:
return pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr)
def get_embargo_response(request, course_id, user):
"""
Check whether any country access rules block the user from enrollment.
Args:
request (HttpRequest): The request object
course_id (str): The requested course ID
user (str): The current user object
Returns:
HttpResponse: Response of the embargo page if embargoed, None if not
"""
redirect_url = redirect_if_blocked(
course_id, user=user, ip_address=get_ip(request), url=request.path)
if redirect_url:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={
"message": (
u"Users from this location cannot access the course '{course_id}'."
).format(course_id=course_id),
"user_message_url": request.build_absolute_uri(redirect_url)
}
)

View File

@@ -26,6 +26,7 @@ from util.models import RateLimitConfiguration
from util.testing import UrlResetMixin
from enrollment import api
from enrollment.errors import CourseEnrollmentError
from openedx.core.lib.django_test_client_utils import get_absolute_url
from openedx.core.djangoapps.user_api.models import UserOrgTag
from student.tests.factories import UserFactory, CourseModeFactory
from student.models import CourseEnrollment
@@ -725,10 +726,6 @@ class EnrollmentEmbargoTest(EnrollmentTestMixin, UrlResetMixin, ModuleStoreTestC
'user': self.user.username
})
def _get_absolute_url(self, path):
""" Generate an absolute URL for a resource on the test server. """
return u'http://testserver/{}'.format(path.lstrip('/'))
def assert_access_denied(self, user_message_path):
"""
Verify that the view returns HTTP status 403 and includes a URL in the response, and no enrollment is created.
@@ -741,7 +738,7 @@ class EnrollmentEmbargoTest(EnrollmentTestMixin, UrlResetMixin, ModuleStoreTestC
# Expect that the redirect URL is included in the response
resp_data = json.loads(response.content)
user_message_url = self._get_absolute_url(user_message_path)
user_message_url = get_absolute_url(user_message_path)
self.assertEqual(resp_data['user_message_url'], user_message_url)
# Verify that we were not enrolled

View File

@@ -32,7 +32,6 @@ from enrollment.errors import (
)
from student.models import User
log = logging.getLogger(__name__)
@@ -91,15 +90,19 @@ class EnrollmentView(APIView, ApiKeyPermissionMixIn):
* course_id: The unique identifier for the course.
* enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created.
* enrollment_start: The date and time that users can begin enrolling in the course.
If null, enrollment opens immediately when the course is created.
* enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends.
* enrollment_end: The date and time after which users cannot enroll for the course.
If null, the enrollment period never ends.
* course_start: The date and time at which the course opens. If null, the course opens immediately when created.
* course_start: The date and time at which the course opens.
If null, the course opens immediately when created.
* course_end: The date and time at which the course closes. If null, the course never ends.
* course_modes: An array of data about the enrollment modes supported for the course. Each enrollment mode collection includes:
* course_modes: An array of data about the enrollment modes supported for the course.
Each enrollment mode collection includes:
* slug: The short name for the enrollment mode.
* name: The full name of the enrollment mode.
@@ -182,20 +185,24 @@ class EnrollmentCourseDetailView(APIView):
**Response Values**
A collection of course enrollments for the user, or for the newly created enrollment. Each course enrollment contains:
A collection of course enrollments for the user, or for the newly created enrollment.
Each course enrollment contains:
* course_id: The unique identifier of the course.
* enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created.
* enrollment_start: The date and time that users can begin enrolling in the course.
If null, enrollment opens immediately when the course is created.
* enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends.
* enrollment_end: The date and time after which users cannot enroll for the course.
If null, the enrollment period never ends.
* course_start: The date and time at which the course opens. If null, the course opens immediately when created.
* course_start: The date and time at which the course opens.
If null, the course opens immediately when created.
* course_end: The date and time at which the course closes. If null, the course never ends.
* course_modes: An array containing details about the enrollment modes supported for the course.
If the request uses the parameter include_expired=1, the array also includes expired enrollment modes.
If the request uses the parameter include_expired=1, the array also includes expired enrollment modes.
Each enrollment mode collection includes:
@@ -204,7 +211,8 @@ class EnrollmentCourseDetailView(APIView):
* min_price: The minimum price for which a user can enroll in this mode.
* suggested_prices: A list of suggested prices for this enrollment mode.
* currency: The currency of the listed prices.
* expiration_datetime: The date and time after which users cannot enroll in the course in this mode.
* expiration_datetime: The date and time after which users cannot enroll in the course
in this mode.
* description: A description of this mode.
* invite_only: Whether students must be invited to enroll in the course; true or false.
@@ -267,7 +275,8 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
**Post Parameters**
* user: The user ID of the currently logged in user. Optional. You cannot use the command to enroll a different user.
* user: The username of the currently logged in user. Optional.
You cannot use the command to enroll a different user.
* mode: The Course Mode for the enrollment. Individual users cannot upgrade their enrollment mode from
'honor'. Only server-to-server requests can enroll with other modes. Optional.
@@ -284,7 +293,8 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
**Response Values**
A collection of course enrollments for the user, or for the newly created enrollment. Each course enrollment contains:
A collection of course enrollments for the user, or for the newly created enrollment.
Each course enrollment contains:
* created: The date the user account was created.
@@ -296,28 +306,33 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
* course_id: The unique identifier for the course.
* enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created.
* enrollment_start: The date and time that users can begin enrolling in the course.
If null, enrollment opens immediately when the course is created.
* enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends.
* enrollment_end: The date and time after which users cannot enroll for the course.
If null, the enrollment period never ends.
* course_start: The date and time at which the course opens. If null, the course opens immediately when created.
* course_start: The date and time at which the course opens.
If null, the course opens immediately when created.
* course_end: The date and time at which the course closes. If null, the course never ends.
* course_modes: An array of data about the enrollment modes supported for the course. Each enrollment mode collection includes:
* course_modes: An array of data about the enrollment modes supported for the course.
Each enrollment mode collection includes:
* slug: The short name for the enrollment mode.
* name: The full name of the enrollment mode.
* min_price: The minimum price for which a user can enroll in this mode.
* suggested_prices: A list of suggested prices for this enrollment mode.
* currency: The currency of the listed prices.
* expiration_datetime: The date and time after which users cannot enroll in the course in this mode.
* expiration_datetime: The date and time after which users cannot enroll in the course
in this mode.
* description: A description of this mode.
* invite_only: Whether students must be invited to enroll in the course; true or false.
* user: The ID of the user.
* user: The username of the user.
"""
authentication_classes = OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth
@@ -406,21 +421,10 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
}
)
# Check whether any country access rules block the user from enrollment
# We do this at the view level (rather than the Python API level)
# because this check requires information about the HTTP request.
redirect_url = embargo_api.redirect_if_blocked(
course_id, user=user, ip_address=get_ip(request), url=request.path)
if redirect_url:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={
"message": (
u"Users from this location cannot access the course '{course_id}'."
).format(course_id=course_id),
"user_message_url": request.build_absolute_uri(redirect_url)
}
)
embargo_response = embargo_api.get_embargo_response(request, course_id, user)
if embargo_response:
return embargo_response
try:
is_active = request.DATA.get('is_active')

View File

@@ -4,6 +4,9 @@ Middleware for Language Preferences
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from lang_pref import LANGUAGE_KEY
# TODO PLAT-671 Import from Django 1.8
# from django.utils.translation import LANGUAGE_SESSION_KEY
from django_locale.trans_real import LANGUAGE_SESSION_KEY
class LanguagePreferenceMiddleware(object):
@@ -16,10 +19,12 @@ class LanguagePreferenceMiddleware(object):
def process_request(self, request):
"""
If a user's UserPreference contains a language preference and there is
no language set on the session (i.e. from dark language overrides), use the user's preference.
If a user's UserPreference contains a language preference, use the user's preference.
"""
if request.user.is_authenticated() and 'django_language' not in request.session:
# If the user is logged in, check for their language preference
if request.user.is_authenticated():
# Get the user's language preference
user_pref = get_user_preference(request.user, LANGUAGE_KEY)
# Set it to the LANGUAGE_SESSION_KEY (Django-specific session setting governing language pref)
if user_pref:
request.session['django_language'] = user_pref
request.session[LANGUAGE_SESSION_KEY] = user_pref

View File

@@ -1,6 +1,9 @@
from django.test import TestCase
from django.test.client import RequestFactory
from django.contrib.sessions.middleware import SessionMiddleware
# TODO PLAT-671 Import from Django 1.8
# from django.utils.translation import LANGUAGE_SESSION_KEY
from django_locale.trans_real import LANGUAGE_SESSION_KEY
from lang_pref.middleware import LanguagePreferenceMiddleware
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
@@ -25,19 +28,23 @@ class TestUserPreferenceMiddleware(TestCase):
def test_no_language_set_in_session_or_prefs(self):
# nothing set in the session or the prefs
self.middleware.process_request(self.request)
self.assertNotIn('django_language', self.request.session)
self.assertNotIn(LANGUAGE_SESSION_KEY, self.request.session)
def test_language_in_user_prefs(self):
# language set in the user preferences and not the session
set_user_preference(self.user, LANGUAGE_KEY, 'eo')
self.middleware.process_request(self.request)
self.assertEquals(self.request.session['django_language'], 'eo')
self.assertEquals(self.request.session[LANGUAGE_SESSION_KEY], 'eo')
def test_language_in_session(self):
# language set in both the user preferences and session,
# session should get precedence
self.request.session['django_language'] = 'en'
# preference should get precedence. The session will hold the last value,
# which is probably the user's last preference. Look up the updated preference.
# Dark lang middleware should run after this middleware, so it can
# set a session language as an override of the user's preference.
self.request.session[LANGUAGE_SESSION_KEY] = 'en'
set_user_preference(self.user, LANGUAGE_KEY, 'eo')
self.middleware.process_request(self.request)
self.assertEquals(self.request.session['django_language'], 'en')
self.assertEquals(self.request.session[LANGUAGE_SESSION_KEY], 'eo')

View File

@@ -0,0 +1,150 @@
"""
Utility functions for setting "logged in" cookies used by subdomains.
"""
import time
import json
from django.utils.http import cookie_date
from django.conf import settings
from django.core.urlresolvers import reverse, NoReverseMatch
def set_logged_in_cookies(request, response, user):
"""
Set cookies 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)
Currently, two cookies are set:
* EDXMKTG_LOGGED_IN_COOKIE_NAME: Set to 'true' if the user is logged in.
* EDXMKTG_USER_INFO_COOKIE_VERSION: JSON-encoded dictionary with user information (see below).
The user info cookie has the following format:
{
"version": 1,
"username": "test-user",
"email": "test-user@example.com",
"header_urls": {
"account_settings": "https://example.com/account/settings",
"learner_profile": "https://example.com/u/test-user",
"logout": "https://example.com/logout"
}
}
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.
user (User): The currently logged in user.
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)
cookie_settings = {
'max_age': max_age,
'expires': expires,
'domain': settings.SESSION_COOKIE_DOMAIN,
'path': '/',
'httponly': None,
}
# Backwards compatibility: set the cookie indicating that the user
# is logged in. This is just a boolean value, so it's not very useful.
# In the future, we should be able to replace this with the "user info"
# cookie set below.
response.set_cookie(
settings.EDXMKTG_LOGGED_IN_COOKIE_NAME.encode('utf-8'),
'true',
secure=None,
**cookie_settings
)
# Set a cookie with user info. This can be used by external sites
# to customize content based on user information. Currently,
# we include information that's used to customize the "account"
# links in the header of subdomain sites (such as the marketing site).
header_urls = {'logout': reverse('logout')}
# Unfortunately, this app is currently used by both the LMS and Studio login pages.
# If we're in Studio, we won't be able to reverse the account/profile URLs.
# To handle this, we don't add the URLs if we can't reverse them.
# External sites will need to have fallback mechanisms to handle this case
# (most likely just hiding the links).
try:
header_urls['account_settings'] = reverse('account_settings')
header_urls['learner_profile'] = reverse('learner_profile', kwargs={'username': user.username})
except NoReverseMatch:
pass
# Convert relative URL paths to absolute URIs
for url_name, url_path in header_urls.iteritems():
header_urls[url_name] = request.build_absolute_uri(url_path)
user_info = {
'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
'username': user.username,
'email': user.email,
'header_urls': header_urls,
}
# In production, TLS should be enabled so that this cookie is encrypted
# when we send it. We also need to set "secure" to True so that the browser
# will transmit it only over secure connections.
#
# In non-production environments (acceptance tests, devstack, and sandboxes),
# we still want to set this cookie. However, we do NOT want to set it to "secure"
# because the browser won't send it back to us. This can cause an infinite redirect
# loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine
# whether it needs to set the cookie or continue to the next pipeline stage.
user_info_cookie_is_secure = request.is_secure()
response.set_cookie(
settings.EDXMKTG_USER_INFO_COOKIE_NAME.encode('utf-8'),
json.dumps(user_info),
secure=user_info_cookie_is_secure,
**cookie_settings
)
return response
def delete_logged_in_cookies(response):
"""
Delete cookies indicating that the user is logged in.
Arguments:
response (HttpResponse): The response sent to the client.
Returns:
HttpResponse
"""
for cookie_name in [settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, settings.EDXMKTG_USER_INFO_COOKIE_NAME]:
response.delete_cookie(
cookie_name.encode('utf-8'),
path='/',
domain=settings.SESSION_COOKIE_DOMAIN
)
return response
def is_logged_in_cookie_set(request):
"""Check whether the request has logged in cookies set. """
return (
settings.EDXMKTG_LOGGED_IN_COOKIE_NAME in request.COOKIES and
settings.EDXMKTG_USER_INFO_COOKIE_NAME in request.COOKIES
)

View File

@@ -1,54 +1,19 @@
"""Helpers for the student app. """
import time
from datetime import datetime
import urllib
from pytz import UTC
from django.utils.http import cookie_date
from django.conf import settings
from django.core.urlresolvers import reverse, NoReverseMatch
import third_party_auth
import urllib
from verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=F0401
from course_modes.models import CourseMode
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
# Enumeration of per-course verification statuses
# we display on the student dashboard.
VERIFY_STATUS_NEED_TO_VERIFY = "verify_need_to_verify"
@@ -231,8 +196,9 @@ def auth_pipeline_urls(auth_entry, redirect_url=None):
return {}
return {
provider.NAME: third_party_auth.pipeline.get_login_url(provider.NAME, auth_entry, redirect_url=redirect_url)
for provider in third_party_auth.provider.Registry.enabled()
provider.provider_id: third_party_auth.pipeline.get_login_url(
provider.provider_id, auth_entry, redirect_url=redirect_url
) for provider in third_party_auth.provider.Registry.enabled()
}

View File

@@ -1315,6 +1315,14 @@ class CourseEnrollment(models.Model):
def course(self):
return modulestore().get_course(self.course_id)
@property
def course_overview(self):
"""
Return a CourseOverview of this enrollment's course.
"""
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
return CourseOverview.get_from_id(self.course_id)
def is_verified_enrollment(self):
"""
Check the course enrollment mode is verified or not

View File

@@ -1,65 +0,0 @@
"""
Unit tests for change_name view of student.
"""
import json
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test.client import Client
from django.test import TestCase
from student.tests.factories import UserFactory
from student.models import UserProfile
import unittest
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TestChangeName(TestCase):
"""
Check the change_name view of student.
"""
def setUp(self):
super(TestChangeName, self).setUp()
self.student = UserFactory.create(password='test')
self.client = Client()
def test_change_name_get_request(self):
"""Get requests are not allowed in this view."""
change_name_url = reverse('change_name')
resp = self.client.get(change_name_url)
self.assertEquals(resp.status_code, 405)
def test_change_name_post_request(self):
"""Name will be changed when provided with proper data."""
self.client.login(username=self.student.username, password='test')
change_name_url = reverse('change_name')
resp = self.client.post(change_name_url, {
'new_name': 'waqas',
'rationale': 'change identity'
})
response_data = json.loads(resp.content)
user = UserProfile.objects.get(user=self.student.id)
meta = json.loads(user.meta)
self.assertEquals(user.name, 'waqas')
self.assertEqual(meta['old_names'][0][1], 'change identity')
self.assertTrue(response_data['success'])
def test_change_name_without_name(self):
"""Empty string for name is not allowed in this view."""
self.client.login(username=self.student.username, password='test')
change_name_url = reverse('change_name')
resp = self.client.post(change_name_url, {
'new_name': '',
'rationale': 'change identity'
})
response_data = json.loads(resp.content)
self.assertFalse(response_data['success'])
def test_unauthenticated(self):
"""Unauthenticated user is not allowed to call this view."""
change_name_url = reverse('change_name')
resp = self.client.post(change_name_url, {
'new_name': 'waqas',
'rationale': 'change identity'
})
self.assertEquals(resp.status_code, 404)

View File

@@ -86,7 +86,8 @@ class TestCreateAccount(TestCase):
def test_marketing_cookie(self):
response = self.client.post(self.url, self.params)
self.assertEqual(response.status_code, 200)
self.assertIn(settings.EDXMKTG_COOKIE_NAME, self.client.cookies)
self.assertIn(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, self.client.cookies)
self.assertIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies)
@unittest.skipUnless(
"microsite_configuration.middleware.MicrositeMiddleware" in settings.MIDDLEWARE_CLASSES,

View File

@@ -6,6 +6,7 @@ import unittest
from django.test import TestCase
from django.test.client import Client
from django.test.utils import override_settings
from django.conf import settings
from django.core.cache import cache
from django.core.urlresolvers import reverse, NoReverseMatch
@@ -158,6 +159,57 @@ class LoginTest(TestCase):
self.assertEqual(response.status_code, 302)
self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test'])
def test_login_user_info_cookie(self):
response, _ = self._login_response('test@edx.org', 'test_password')
self._assert_response(response, success=True)
# Verify the format of the "user info" cookie set on login
cookie = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME]
user_info = json.loads(cookie.value)
# Check that the version is set
self.assertEqual(user_info["version"], settings.EDXMKTG_USER_INFO_COOKIE_VERSION)
# Check that the username and email are set
self.assertEqual(user_info["username"], self.user.username)
self.assertEqual(user_info["email"], self.user.email)
# Check that the URLs are absolute
for url in user_info["header_urls"].values():
self.assertIn("http://testserver/", url)
def test_logout_deletes_mktg_cookies(self):
response, _ = self._login_response('test@edx.org', 'test_password')
self._assert_response(response, success=True)
# Check that the marketing site cookies have been set
self.assertIn(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, self.client.cookies)
self.assertIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies)
# Log out
logout_url = reverse('logout')
response = self.client.post(logout_url)
# Check that the marketing site cookies have been deleted
# (cookies are deleted by setting an expiration date in 1970)
for cookie_name in [settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, settings.EDXMKTG_USER_INFO_COOKIE_NAME]:
cookie = self.client.cookies[cookie_name]
self.assertIn("01-Jan-1970", cookie.get('expires'))
@override_settings(
EDXMKTG_LOGGED_IN_COOKIE_NAME=u"unicode-logged-in",
EDXMKTG_USER_INFO_COOKIE_NAME=u"unicode-user-info",
)
def test_unicode_mktg_cookie_names(self):
# When logged in cookie names are loaded from JSON files, they may
# have type `unicode` instead of `str`, which can cause errors
# when calling Django cookie manipulation functions.
response, _ = self._login_response('test@edx.org', 'test_password')
self._assert_response(response, success=True)
response = self.client.post(reverse('logout'))
self.assertRedirects(response, "/")
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
def test_logout_logging_no_pii(self):
response, _ = self._login_response('test@edx.org', 'test_password')

View File

@@ -11,12 +11,12 @@ from django.core.urlresolvers import reverse
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import CourseModeFactory
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
# 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`
# This relies on third party auth being enabled in the test
# settings with the feature flag `ENABLE_THIRD_PARTY_AUTH`
THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"]
THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"]
@@ -40,7 +40,7 @@ def _finish_auth_url(params):
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase):
"""Test rendering of the login form. """
@patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False})
def setUp(self):
@@ -50,6 +50,8 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
self.course = CourseFactory.create()
self.course_id = unicode(self.course.id)
self.courseware_url = reverse("courseware", args=[self.course_id])
self.configure_google_provider(enabled=True)
self.configure_facebook_provider(enabled=True)
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data(THIRD_PARTY_AUTH_PROVIDERS)
@@ -148,7 +150,7 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase):
class RegisterFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase):
"""Test rendering of the registration form. """
@patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False})
def setUp(self):
@@ -157,6 +159,8 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase):
self.url = reverse("register_user")
self.course = CourseFactory.create()
self.course_id = unicode(self.course.id)
self.configure_google_provider(enabled=True)
self.configure_facebook_provider(enabled=True)
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data(*THIRD_PARTY_AUTH_PROVIDERS)

View File

@@ -7,8 +7,10 @@ import uuid
import time
import json
import warnings
from datetime import timedelta
from collections import defaultdict
from pytz import UTC
from requests import HTTPError
from ipware.ip import get_ip
from django.conf import settings
@@ -25,21 +27,19 @@ from django.db import IntegrityError, transaction
from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,
HttpResponseServerError, Http404)
from django.shortcuts import redirect
from django.utils import timezone
from django.utils.translation import ungettext
from django.utils.http import cookie_date, base36_to_int
from django.utils.translation import ugettext as _, get_language
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.views.decorators.http import require_POST, require_GET
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.template.response import TemplateResponse
from ratelimitbackend.exceptions import RateLimitException
from requests import HTTPError
from social.apps.django_app import utils as social_utils
from social.backends import oauth as social_oauth
@@ -106,9 +106,10 @@ from util.password_policy_validators import (
import third_party_auth
from third_party_auth import pipeline, provider
from student.helpers import (
set_logged_in_cookie, check_verify_status_by_course,
check_verify_status_by_course,
auth_pipeline_urls, get_next_url_for_login_page
)
from student.cookies import set_logged_in_cookies, delete_logged_in_cookies
from student.models import anonymous_id_for_user
from xmodule.error_module import ErrorDescriptor
from shoppingcart.models import DonationConfiguration, CourseRegistrationCode
@@ -123,13 +124,12 @@ from notification_prefs.views import enable_notifications
# Note that this lives in openedx, so this dependency should be refactored.
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
from openedx.core.djangoapps.credit.api import get_credit_eligibility, get_purchased_credit_courses
log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit")
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
@@ -368,7 +368,7 @@ def signin_user(request):
for msg in messages.get_messages(request):
if msg.extra_tags.split()[0] == "social-auth":
# msg may or may not be translated. Try translating [again] in case we are able to:
third_party_auth_error = _(msg) # pylint: disable=translation-of-non-string
third_party_auth_error = _(unicode(msg)) # pylint: disable=translation-of-non-string
break
context = {
@@ -424,10 +424,10 @@ def register_user(request, extra_context=None):
# selected provider.
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
current_provider = provider.Registry.get_from_pipeline(running_pipeline)
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
overrides['running_pipeline'] = running_pipeline
overrides['selected_provider'] = current_provider.NAME
overrides['selected_provider'] = current_provider.name
context.update(overrides)
return render_to_response('register.html', context)
@@ -526,6 +526,13 @@ def dashboard(request):
course_enrollment_pairs, course_modes_by_course
)
# Retrieve the course modes for each course
enrolled_courses_dict = {}
for course, __ in course_enrollment_pairs:
enrolled_courses_dict[unicode(course.id)] = course
credit_messages = _create_credit_availability_message(enrolled_courses_dict, user)
course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
message = ""
@@ -631,6 +638,7 @@ def dashboard(request):
context = {
'enrollment_message': enrollment_message,
'credit_messages': credit_messages,
'course_enrollment_pairs': course_enrollment_pairs,
'course_optouts': course_optouts,
'message': message,
@@ -695,6 +703,47 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes):
)
def _create_credit_availability_message(enrolled_courses_dict, user): # pylint: disable=invalid-name
"""Builds a dict of credit availability for courses.
Construct a for courses user has completed and has not purchased credit
from the credit provider yet.
Args:
course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information.
user (User): User object.
Returns:
A dict of courses user is eligible for credit.
"""
user_eligibilities = get_credit_eligibility(user.username)
user_purchased_credit = get_purchased_credit_courses(user.username)
eligibility_messages = {}
for course_id, eligibility in user_eligibilities.iteritems():
if course_id not in user_purchased_credit:
duration = eligibility["seconds_good_for_display"]
curr_time = timezone.now()
validity_till = eligibility["created_at"] + timedelta(seconds=duration)
if validity_till > curr_time:
diff = validity_till - curr_time
urgent = diff.days <= 30
eligibility_messages[course_id] = {
"user_id": user.id,
"course_id": course_id,
"course_name": enrolled_courses_dict[course_id].display_name,
"providers": eligibility["providers"],
"status": eligibility["status"],
"provider": eligibility.get("provider"),
"urgent": urgent,
"user_full_name": user.get_full_name(),
"expiry": validity_till
}
return eligibility_messages
def _get_recently_enrolled_courses(course_enrollment_pairs):
"""Checks to see if the student has recently enrolled in courses.
@@ -903,10 +952,11 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
running_pipeline = pipeline.get(request)
username = running_pipeline['kwargs'].get('username')
backend_name = running_pipeline['backend']
requested_provider = provider.Registry.get_by_backend_name(backend_name)
third_party_uid = running_pipeline['kwargs']['uid']
requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
try:
user = pipeline.get_authenticated_user(username, backend_name)
user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
third_party_auth_successful = True
except User.DoesNotExist:
AUDIT_LOG.warning(
@@ -914,12 +964,12 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
username=username, backend_name=backend_name))
return HttpResponse(
_("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format(
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.name
)
+ "<br/><br/>" +
_("Use your {platform_name} username and password to log into {platform_name} below, "
"and then link your {platform_name} account with {provider_name} from your dashboard.").format(
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.name
)
+ "<br/><br/>" +
_("If you don't have an {platform_name} account yet, click <strong>Register Now</strong> at the top of the page.").format(
@@ -1067,7 +1117,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
# Ensure that the external marketing site can
# detect that the user is logged in.
return set_logged_in_cookie(request, response)
return set_logged_in_cookies(request, response, user)
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))
@@ -1131,10 +1181,8 @@ def logout_user(request):
else:
target = '/'
response = redirect(target)
response.delete_cookie(
settings.EDXMKTG_COOKIE_NAME,
path='/', domain=settings.SESSION_COOKIE_DOMAIN,
)
delete_logged_in_cookies(response)
return response
@@ -1450,6 +1498,13 @@ def create_account_with_params(request, params):
dog_stats_api.increment("common.student.account_created")
# If the user is registering via 3rd party auth, track which provider they use
third_party_provider = None
running_pipeline = None
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)
# Track the user's registration
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
tracking_context = tracker.get_tracker().resolve_context()
@@ -1458,20 +1513,13 @@ def create_account_with_params(request, params):
'username': user.username,
})
# If the user is registering via 3rd party auth, track which provider they use
provider_name = None
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
provider_name = current_provider.NAME
analytics.track(
user.id,
"edx.bi.user.account.registered",
{
'category': 'conversion',
'label': params.get('course_id'),
'provider': provider_name
'provider': third_party_provider.name if third_party_provider else None
},
context={
'Google Analytics': {
@@ -1488,6 +1536,7 @@ def create_account_with_params(request, params):
# 2. Random user generation for other forms of testing.
# 3. External auth bypassing activation.
# 4. Have the platform configured to not require e-mail activation.
# 5. Registering a new user using a trusted third party provider (with skip_email_verification=True)
#
# Note that this feature is only tested as a flag set one way or
# the other for *new* systems. we need to be careful about
@@ -1496,7 +1545,11 @@ def create_account_with_params(request, params):
send_email = (
not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and
not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and
not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'))
not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and
not (
third_party_provider and third_party_provider.skip_email_verification and
user.email == running_pipeline['kwargs'].get('details', {}).get('email')
)
)
if send_email:
context = {
@@ -1552,32 +1605,7 @@ def create_account_with_params(request, params):
new_user.save()
AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email))
def set_marketing_cookie(request, response):
"""
Set the login cookie for the edx marketing site on the given response. Its
expiration will match that of the given request's session.
"""
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)
# we want this cookie to be accessed via javascript
# so httponly is set to None
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 new_user
@csrf_exempt
@@ -1589,7 +1617,7 @@ def create_account(request, post_override=None):
warnings.warn("Please use RegistrationView instead.", DeprecationWarning)
try:
create_account_with_params(request, post_override or request.POST)
user = create_account_with_params(request, post_override or request.POST)
except AccountValidationError as exc:
return JsonResponse({'success': False, 'value': exc.message, 'field': exc.field}, status=400)
except ValidationError as exc:
@@ -1614,7 +1642,7 @@ def create_account(request, post_override=None):
'success': True,
'redirect_url': redirect_url,
})
set_marketing_cookie(request, response)
set_logged_in_cookies(request, response, user)
return response
@@ -2081,66 +2109,6 @@ def confirm_email_change(request, key): # pylint: disable=unused-argument
raise
# TODO: DELETE AFTER NEW ACCOUNT PAGE DONE
@ensure_csrf_cookie
@require_POST
def change_name_request(request):
""" Log a request for a new name. """
if not request.user.is_authenticated():
raise Http404
try:
pnc = PendingNameChange.objects.get(user=request.user.id)
except PendingNameChange.DoesNotExist:
pnc = PendingNameChange()
pnc.user = request.user
pnc.new_name = request.POST['new_name'].strip()
pnc.rationale = request.POST['rationale']
if len(pnc.new_name) < 2:
return JsonResponse({
"success": False,
"error": _('Name required'),
}) # TODO: this should be status code 400 # pylint: disable=fixme
pnc.save()
# The following automatically accepts name change requests. Remove this to
# go back to the old system where it gets queued up for admin approval.
accept_name_change_by_id(pnc.id)
return JsonResponse({"success": True})
# TODO: DELETE AFTER NEW ACCOUNT PAGE DONE
def accept_name_change_by_id(uid):
"""
Accepts the pending name change request for the user represented
by user id `uid`.
"""
try:
pnc = PendingNameChange.objects.get(id=uid)
except PendingNameChange.DoesNotExist:
return JsonResponse({
"success": False,
"error": _('Invalid ID'),
}) # TODO: this should be status code 400 # pylint: disable=fixme
user = pnc.user
u_prof = UserProfile.objects.get(user=user)
# Save old name
meta = u_prof.get_meta()
if 'old_names' not in meta:
meta['old_names'] = []
meta['old_names'].append([u_prof.name, pnc.rationale, datetime.datetime.now(UTC).isoformat()])
u_prof.set_meta(meta)
u_prof.name = pnc.new_name
u_prof.save()
pnc.delete()
return JsonResponse({"success": True})
@require_POST
@login_required
@ensure_csrf_cookie

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""
Admin site configuration for third party authentication
"""
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData
from .tasks import fetch_saml_metadata
admin.site.register(OAuth2ProviderConfig, KeyedConfigurationModelAdmin)
class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin):
""" Django Admin class for SAMLProviderConfig """
def get_list_display(self, request):
""" Don't show every single field in the admin change list """
return (
'name', 'enabled', 'backend_name', 'entity_id', 'metadata_source',
'has_data', 'icon_class', 'change_date', 'changed_by', 'edit_link'
)
def has_data(self, inst):
""" Do we have cached metadata for this SAML provider? """
if not inst.is_active:
return None # N/A
data = SAMLProviderData.current(inst.entity_id)
return bool(data and data.is_valid())
has_data.short_description = u'Metadata Ready'
has_data.boolean = True
def save_model(self, request, obj, form, change):
"""
Post save: Queue an asynchronous metadata fetch to update SAMLProviderData.
We only want to do this for manual edits done using the admin interface.
Note: This only works if the celery worker and the app worker are using the
same 'configuration' cache.
"""
super(SAMLProviderConfigAdmin, self).save_model(request, obj, form, change)
fetch_saml_metadata.apply_async((), countdown=2)
admin.site.register(SAMLProviderConfig, SAMLProviderConfigAdmin)
class SAMLConfigurationAdmin(ConfigurationModelAdmin):
""" Django Admin class for SAMLConfiguration """
def get_list_display(self, request):
""" Shorten the public/private keys in the change view """
return (
'change_date', 'changed_by', 'enabled', 'entity_id',
'org_info_str', 'key_summary',
)
def key_summary(self, inst):
""" Short summary of the key pairs configured """
if not inst.public_key or not inst.private_key:
return u'<em>Key pair incomplete/missing</em>'
pub1, pub2 = inst.public_key[0:10], inst.public_key[-10:]
priv1, priv2 = inst.private_key[0:10], inst.private_key[-10:]
return u'Public: {}{}<br>Private: {}{}'.format(pub1, pub2, priv1, priv2)
key_summary.allow_tags = True
admin.site.register(SAMLConfiguration, SAMLConfigurationAdmin)
class SAMLProviderDataAdmin(admin.ModelAdmin):
""" Django Admin class for SAMLProviderData (Read Only) """
list_display = ('entity_id', 'is_valid', 'fetched_at', 'expires_at', 'sso_url')
readonly_fields = ('is_valid', )
def get_readonly_fields(self, request, obj=None):
if obj: # editing an existing object
return self.model._meta.get_all_field_names() # pylint: disable=protected-access
return self.readonly_fields
admin.site.register(SAMLProviderData, SAMLProviderDataAdmin)

View File

@@ -1,13 +1,11 @@
"""
DummyProvider: A fake Third Party Auth provider for testing & development purposes.
DummyBackend: A fake Third Party Auth provider for testing & development purposes.
"""
from social.backends.base import BaseAuth
from social.backends.oauth import BaseOAuth2
from social.exceptions import AuthFailed
from .provider import BaseProvider
class DummyBackend(BaseAuth): # pylint: disable=abstract-method
class DummyBackend(BaseOAuth2): # pylint: disable=abstract-method
"""
python-social-auth backend that doesn't actually go to any third party site
"""
@@ -47,12 +45,3 @@ class DummyBackend(BaseAuth): # pylint: disable=abstract-method
kwargs.update({'response': response, 'backend': self})
return self.strategy.authenticate(*args, **kwargs)
class DummyProvider(BaseProvider):
""" Dummy Provider for testing and development """
BACKEND_CLASS = DummyBackend
ICON_CLASS = 'fa-cube'
NAME = 'Dummy'
SETTINGS = {}

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""
Management commands for third_party_auth
"""
from django.core.management.base import BaseCommand, CommandError
import logging
from third_party_auth.models import SAMLConfiguration
from third_party_auth.tasks import fetch_saml_metadata
class Command(BaseCommand):
""" manage.py commands to manage SAML/Shibboleth SSO """
help = '''Configure/maintain/update SAML-based SSO'''
def handle(self, *args, **options):
if len(args) != 1:
raise CommandError("saml requires one argument: pull")
if not SAMLConfiguration.is_enabled():
raise CommandError("SAML support is disabled via SAMLConfiguration.")
subcommand = args[0]
if subcommand == "pull":
log_handler = logging.StreamHandler(self.stdout)
log_handler.setLevel(logging.DEBUG)
log = logging.getLogger('third_party_auth.tasks')
log.propagate = False
log.addHandler(log_handler)
num_changed, num_failed, num_total = fetch_saml_metadata()
self.stdout.write(
"\nDone. Fetched {num_total} total. {num_changed} were updated and {num_failed} failed.\n".format(
num_changed=num_changed, num_failed=num_failed, num_total=num_total
)
)
else:
raise CommandError("Unknown argment: {}".format(subcommand))

View File

@@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'OAuth2ProviderConfig'
db.create_table('third_party_auth_oauth2providerconfig', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
('icon_class', self.gf('django.db.models.fields.CharField')(default='fa-sign-in', max_length=50)),
('name', self.gf('django.db.models.fields.CharField')(max_length=50)),
('backend_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
('key', self.gf('django.db.models.fields.TextField')(blank=True)),
('secret', self.gf('django.db.models.fields.TextField')(blank=True)),
('other_settings', self.gf('django.db.models.fields.TextField')(blank=True)),
))
db.send_create_signal('third_party_auth', ['OAuth2ProviderConfig'])
# Adding model 'SAMLProviderConfig'
db.create_table('third_party_auth_samlproviderconfig', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
('icon_class', self.gf('django.db.models.fields.CharField')(default='fa-sign-in', max_length=50)),
('name', self.gf('django.db.models.fields.CharField')(max_length=50)),
('backend_name', self.gf('django.db.models.fields.CharField')(default='tpa-saml', max_length=50)),
('idp_slug', self.gf('django.db.models.fields.SlugField')(max_length=30)),
('entity_id', self.gf('django.db.models.fields.CharField')(max_length=255)),
('metadata_source', self.gf('django.db.models.fields.CharField')(max_length=255)),
('attr_user_permanent_id', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
('attr_full_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
('attr_first_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
('attr_last_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
('attr_username', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
('attr_email', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
('other_settings', self.gf('django.db.models.fields.TextField')(blank=True)),
))
db.send_create_signal('third_party_auth', ['SAMLProviderConfig'])
# Adding model 'SAMLConfiguration'
db.create_table('third_party_auth_samlconfiguration', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
('private_key', self.gf('django.db.models.fields.TextField')()),
('public_key', self.gf('django.db.models.fields.TextField')()),
('entity_id', self.gf('django.db.models.fields.CharField')(default='http://saml.example.com', max_length=255)),
('org_info_str', self.gf('django.db.models.fields.TextField')(default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}')),
('other_config_str', self.gf('django.db.models.fields.TextField')(default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}')),
))
db.send_create_signal('third_party_auth', ['SAMLConfiguration'])
# Adding model 'SAMLProviderData'
db.create_table('third_party_auth_samlproviderdata', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('fetched_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
('expires_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
('entity_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('sso_url', self.gf('django.db.models.fields.URLField')(max_length=200)),
('public_key', self.gf('django.db.models.fields.TextField')()),
))
db.send_create_signal('third_party_auth', ['SAMLProviderData'])
def backwards(self, orm):
# Deleting model 'OAuth2ProviderConfig'
db.delete_table('third_party_auth_oauth2providerconfig')
# Deleting model 'SAMLProviderConfig'
db.delete_table('third_party_auth_samlproviderconfig')
# Deleting model 'SAMLConfiguration'
db.delete_table('third_party_auth_samlconfiguration')
# Deleting model 'SAMLProviderData'
db.delete_table('third_party_auth_samlproviderdata')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'third_party_auth.oauth2providerconfig': {
'Meta': {'object_name': 'OAuth2ProviderConfig'},
'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'})
},
'third_party_auth.samlconfiguration': {
'Meta': {'object_name': 'SAMLConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}),
'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}),
'private_key': ('django.db.models.fields.TextField', [], {}),
'public_key': ('django.db.models.fields.TextField', [], {})
},
'third_party_auth.samlproviderconfig': {
'Meta': {'object_name': 'SAMLProviderConfig'},
'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}),
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}),
'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'})
},
'third_party_auth.samlproviderdata': {
'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'},
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'public_key': ('django.db.models.fields.TextField', [], {}),
'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
}
}
complete_apps = ['third_party_auth']

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
from django.conf import settings
import json
from south.v2 import DataMigration
class Migration(DataMigration):
def forwards(self, orm):
""" Convert from the THIRD_PARTY_AUTH setting to OAuth2ProviderConfig """
tpa = getattr(settings, 'THIRD_PARTY_AUTH_OLD_CONFIG', {})
if tpa and not any(orm.OAuth2ProviderConfig.objects.all()):
print("Migrating third party auth config to OAuth2ProviderConfig")
providers = (
# Name, backend, icon, prefix
('Google', 'google-oauth2', 'fa-google-plus', 'SOCIAL_AUTH_GOOGLE_OAUTH2_'),
('LinkedIn', 'linkedin-oauth2', 'fa-linkedin', 'SOCIAL_AUTH_LINKEDIN_OAUTH2_'),
('Facebook', 'facebook', 'fa-facebook', 'SOCIAL_AUTH_FACEBOOK_'),
)
for name, backend, icon, prefix in providers:
if name in tpa:
conf = tpa[name]
conf = {key.replace(prefix, ''): val for key, val in conf.items()}
key = conf.pop('KEY', '')
secret = conf.pop('SECRET', '')
orm.OAuth2ProviderConfig.objects.create(
enabled=True,
name=name,
backend_name=backend,
icon_class=icon,
key=key,
secret=secret,
other_settings=json.dumps(conf),
changed_by=None,
)
print(
"Done. Make changes via /admin/third_party_auth/oauth2providerconfig/ "
"from now on. You can remove THIRD_PARTY_AUTH from ~/lms.auth.json"
)
else:
print("Not migrating third party auth config to OAuth2ProviderConfig.")
def backwards(self, orm):
""" No backwards migration necessary """
pass
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'third_party_auth.oauth2providerconfig': {
'Meta': {'object_name': 'OAuth2ProviderConfig'},
'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'})
},
'third_party_auth.samlconfiguration': {
'Meta': {'object_name': 'SAMLConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}),
'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}),
'private_key': ('django.db.models.fields.TextField', [], {}),
'public_key': ('django.db.models.fields.TextField', [], {})
},
'third_party_auth.samlproviderconfig': {
'Meta': {'object_name': 'SAMLProviderConfig'},
'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}),
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}),
'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'})
},
'third_party_auth.samlproviderdata': {
'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'},
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'public_key': ('django.db.models.fields.TextField', [], {}),
'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
}
}
complete_apps = ['third_party_auth']
symmetrical = True

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'SAMLProviderConfig.secondary'
db.add_column('third_party_auth_samlproviderconfig', 'secondary',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
# Adding field 'SAMLProviderConfig.skip_registration_form'
db.add_column('third_party_auth_samlproviderconfig', 'skip_registration_form',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
# Adding field 'SAMLProviderConfig.skip_email_verification'
db.add_column('third_party_auth_samlproviderconfig', 'skip_email_verification',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
# Adding field 'OAuth2ProviderConfig.secondary'
db.add_column('third_party_auth_oauth2providerconfig', 'secondary',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
# Adding field 'OAuth2ProviderConfig.skip_registration_form'
db.add_column('third_party_auth_oauth2providerconfig', 'skip_registration_form',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
# Adding field 'OAuth2ProviderConfig.skip_email_verification'
db.add_column('third_party_auth_oauth2providerconfig', 'skip_email_verification',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
def backwards(self, orm):
# Deleting field 'SAMLProviderConfig.secondary'
db.delete_column('third_party_auth_samlproviderconfig', 'secondary')
# Deleting field 'SAMLProviderConfig.skip_registration_form'
db.delete_column('third_party_auth_samlproviderconfig', 'skip_registration_form')
# Deleting field 'SAMLProviderConfig.skip_email_verification'
db.delete_column('third_party_auth_samlproviderconfig', 'skip_email_verification')
# Deleting field 'OAuth2ProviderConfig.secondary'
db.delete_column('third_party_auth_oauth2providerconfig', 'secondary')
# Deleting field 'OAuth2ProviderConfig.skip_registration_form'
db.delete_column('third_party_auth_oauth2providerconfig', 'skip_registration_form')
# Deleting field 'OAuth2ProviderConfig.skip_email_verification'
db.delete_column('third_party_auth_oauth2providerconfig', 'skip_email_verification')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'third_party_auth.oauth2providerconfig': {
'Meta': {'object_name': 'OAuth2ProviderConfig'},
'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
},
'third_party_auth.samlconfiguration': {
'Meta': {'object_name': 'SAMLConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}),
'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}),
'private_key': ('django.db.models.fields.TextField', [], {}),
'public_key': ('django.db.models.fields.TextField', [], {})
},
'third_party_auth.samlproviderconfig': {
'Meta': {'object_name': 'SAMLProviderConfig'},
'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}),
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}),
'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
},
'third_party_auth.samlproviderdata': {
'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'},
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'public_key': ('django.db.models.fields.TextField', [], {}),
'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
}
}
complete_apps = ['third_party_auth']

View File

@@ -0,0 +1,420 @@
# -*- coding: utf-8 -*-
"""
Models used to implement SAML SSO support in third_party_auth
(inlcuding Shibboleth support)
"""
from config_models.models import ConfigurationModel, cache
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
import json
import logging
from social.backends.base import BaseAuth
from social.backends.oauth import BaseOAuth2
from social.backends.saml import SAMLAuth, SAMLIdentityProvider
from social.exceptions import SocialAuthBaseException
from social.utils import module_member
log = logging.getLogger(__name__)
# A dictionary of {name: class} entries for each python-social-auth backend available.
# Because this setting can specify arbitrary code to load and execute, it is set via
# normal Django settings only and cannot be changed at runtime:
def _load_backend_classes(base_class=BaseAuth):
""" Load the list of python-social-auth backend classes from Django settings """
for class_path in settings.AUTHENTICATION_BACKENDS:
auth_class = module_member(class_path)
if issubclass(auth_class, base_class):
yield auth_class
_PSA_BACKENDS = {backend_class.name: backend_class for backend_class in _load_backend_classes()}
_PSA_OAUTH2_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(BaseOAuth2)]
_PSA_SAML_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(SAMLAuth)]
def clean_json(value, of_type):
""" Simple helper method to parse and clean JSON """
if not value.strip():
return json.dumps(of_type())
try:
value_python = json.loads(value)
except ValueError as err:
raise ValidationError("Invalid JSON: {}".format(err.message))
if not isinstance(value_python, of_type):
raise ValidationError("Expected a JSON {}".format(of_type))
return json.dumps(value_python, indent=4)
class AuthNotConfigured(SocialAuthBaseException):
""" Exception when SAMLProviderData or other required info is missing """
def __init__(self, provider_name):
super(AuthNotConfigured, self).__init__()
self.provider_name = provider_name
def __str__(self):
return _('Authentication with {} is currently unavailable.').format( # pylint: disable=no-member
self.provider_name
)
class ProviderConfig(ConfigurationModel):
"""
Abstract Base Class for configuring a third_party_auth provider
"""
icon_class = models.CharField(
max_length=50, default='fa-sign-in',
help_text=(
'The Font Awesome (or custom) icon class to use on the login button for this provider. '
'Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university'
),
)
name = models.CharField(max_length=50, blank=False, help_text="Name of this provider (shown to users)")
secondary = models.BooleanField(
default=False,
help_text=_(
'Secondary providers are displayed less prominently, '
'in a separate list of "Institution" login providers.'
),
)
skip_registration_form = models.BooleanField(
default=False,
help_text=_(
"If this option is enabled, users will not be asked to confirm their details "
"(name, email, etc.) during the registration process. Only select this option "
"for trusted providers that are known to provide accurate user information."
),
)
skip_email_verification = models.BooleanField(
default=False,
help_text=_(
"If this option is selected, users will not be required to confirm their "
"email, and their account will be activated immediately upon registration."
),
)
prefix = None # used for provider_id. Set to a string value in subclass
backend_name = None # Set to a field or fixed value in subclass
# "enabled" field is inherited from ConfigurationModel
class Meta(object): # pylint: disable=missing-docstring
abstract = True
@property
def provider_id(self):
""" Unique string key identifying this provider. Must be URL and css class friendly. """
assert self.prefix is not None
return "-".join((self.prefix, ) + tuple(getattr(self, field) for field in self.KEY_FIELDS))
@property
def backend_class(self):
""" Get the python-social-auth backend class used for this provider """
return _PSA_BACKENDS[self.backend_name]
def get_url_params(self):
""" Get a dict of GET parameters to append to login links for this provider """
return {}
def is_active_for_pipeline(self, pipeline):
""" Is this provider being used for the specified pipeline? """
return self.backend_name == pipeline['backend']
def match_social_auth(self, social_auth):
""" Is this provider being used for this UserSocialAuth entry? """
return self.backend_name == social_auth.provider
@classmethod
def get_register_form_data(cls, pipeline_kwargs):
"""Gets dict of data to display on the register form.
common.djangoapps.student.views.register_user uses this to populate the
new account creation form with values supplied by the user's chosen
provider, preventing duplicate data entry.
Args:
pipeline_kwargs: dict of string -> object. Keyword arguments
accumulated by the pipeline thus far.
Returns:
Dict of string -> string. Keys are names of form fields; values are
values for that field. Where there is no value, the empty string
must be used.
"""
# Details about the user sent back from the provider.
details = pipeline_kwargs.get('details')
# Get the username separately to take advantage of the de-duping logic
# built into the pipeline. The provider cannot de-dupe because it can't
# check the state of taken usernames in our system. Note that there is
# technically a data race between the creation of this value and the
# creation of the user object, so it is still possible for users to get
# an error on submit.
suggested_username = pipeline_kwargs.get('username')
return {
'email': details.get('email', ''),
'name': details.get('fullname', ''),
'username': suggested_username,
}
def get_authentication_backend(self):
"""Gets associated Django settings.AUTHENTICATION_BACKEND string."""
return '{}.{}'.format(self.backend_class.__module__, self.backend_class.__name__)
class OAuth2ProviderConfig(ProviderConfig):
"""
Configuration Entry for an OAuth2 based provider.
"""
prefix = 'oa2'
KEY_FIELDS = ('backend_name', ) # Backend name is unique
backend_name = models.CharField(
max_length=50, choices=[(name, name) for name in _PSA_OAUTH2_BACKENDS], blank=False, db_index=True,
help_text=(
"Which python-social-auth OAuth2 provider backend to use. "
"The list of backend choices is determined by the THIRD_PARTY_AUTH_BACKENDS setting."
# To be precise, it's set by AUTHENTICATION_BACKENDS - which aws.py sets from THIRD_PARTY_AUTH_BACKENDS
)
)
key = models.TextField(blank=True, verbose_name="Client ID")
secret = models.TextField(blank=True, verbose_name="Client Secret")
other_settings = models.TextField(blank=True, help_text="Optional JSON object with advanced settings, if any.")
class Meta(object): # pylint: disable=missing-docstring
verbose_name = "Provider Configuration (OAuth2)"
verbose_name_plural = verbose_name
def clean(self):
""" Standardize and validate fields """
super(OAuth2ProviderConfig, self).clean()
self.other_settings = clean_json(self.other_settings, dict)
def get_setting(self, name):
""" Get the value of a setting, or raise KeyError """
if name in ("KEY", "SECRET"):
return getattr(self, name.lower())
if self.other_settings:
other_settings = json.loads(self.other_settings)
assert isinstance(other_settings, dict), "other_settings should be a JSON object (dictionary)"
return other_settings[name]
raise KeyError
class SAMLProviderConfig(ProviderConfig):
"""
Configuration Entry for a SAML/Shibboleth provider.
"""
prefix = 'saml'
KEY_FIELDS = ('idp_slug', )
backend_name = models.CharField(
max_length=50, default='tpa-saml', choices=[(name, name) for name in _PSA_SAML_BACKENDS], blank=False,
help_text="Which python-social-auth provider backend to use. 'tpa-saml' is the standard edX SAML backend.")
idp_slug = models.SlugField(
max_length=30, db_index=True,
help_text=(
'A short string uniquely identifying this provider. '
'Cannot contain spaces and should be a usable as a CSS class. Examples: "ubc", "mit-staging"'
))
entity_id = models.CharField(
max_length=255, verbose_name="Entity ID", help_text="Example: https://idp.testshib.org/idp/shibboleth")
metadata_source = models.CharField(
max_length=255,
help_text=(
"URL to this provider's XML metadata. Should be an HTTPS URL. "
"Example: https://www.testshib.org/metadata/testshib-providers.xml"
))
attr_user_permanent_id = models.CharField(
max_length=128, blank=True, verbose_name="User ID Attribute",
help_text="URN of the SAML attribute that we can use as a unique, persistent user ID. Leave blank for default.")
attr_full_name = models.CharField(
max_length=128, blank=True, verbose_name="Full Name Attribute",
help_text="URN of SAML attribute containing the user's full name. Leave blank for default.")
attr_first_name = models.CharField(
max_length=128, blank=True, verbose_name="First Name Attribute",
help_text="URN of SAML attribute containing the user's first name. Leave blank for default.")
attr_last_name = models.CharField(
max_length=128, blank=True, verbose_name="Last Name Attribute",
help_text="URN of SAML attribute containing the user's last name. Leave blank for default.")
attr_username = models.CharField(
max_length=128, blank=True, verbose_name="Username Hint Attribute",
help_text="URN of SAML attribute to use as a suggested username for this user. Leave blank for default.")
attr_email = models.CharField(
max_length=128, blank=True, verbose_name="Email Attribute",
help_text="URN of SAML attribute containing the user's email address[es]. Leave blank for default.")
other_settings = models.TextField(
verbose_name="Advanced settings", blank=True,
help_text=(
'For advanced use cases, enter a JSON object with addtional configuration. '
'The tpa-saml backend supports only {"requiredEntitlements": ["urn:..."]} '
'which can be used to require the presence of a specific eduPersonEntitlement.'
))
def clean(self):
""" Standardize and validate fields """
super(SAMLProviderConfig, self).clean()
self.other_settings = clean_json(self.other_settings, dict)
class Meta(object): # pylint: disable=missing-docstring
verbose_name = "Provider Configuration (SAML IdP)"
verbose_name_plural = "Provider Configuration (SAML IdPs)"
def get_url_params(self):
""" Get a dict of GET parameters to append to login links for this provider """
return {'idp': self.idp_slug}
def is_active_for_pipeline(self, pipeline):
""" Is this provider being used for the specified pipeline? """
return self.backend_name == pipeline['backend'] and self.idp_slug == pipeline['kwargs']['response']['idp_name']
def match_social_auth(self, social_auth):
""" Is this provider being used for this UserSocialAuth entry? """
prefix = self.idp_slug + ":"
return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix)
def get_config(self):
"""
Return a SAMLIdentityProvider instance for use by SAMLAuthBackend.
Essentially this just returns the values of this object and its
associated 'SAMLProviderData' entry.
"""
if self.other_settings:
conf = json.loads(self.other_settings)
else:
conf = {}
attrs = (
'attr_user_permanent_id', 'attr_full_name', 'attr_first_name',
'attr_last_name', 'attr_username', 'attr_email', 'entity_id')
for field in attrs:
val = getattr(self, field)
if val:
conf[field] = val
# Now get the data fetched automatically from the metadata.xml:
data = SAMLProviderData.current(self.entity_id)
if not data or not data.is_valid():
log.error("No SAMLProviderData found for %s. Run 'manage.py saml pull' to fix or debug.", self.entity_id)
raise AuthNotConfigured(provider_name=self.name)
conf['x509cert'] = data.public_key
conf['url'] = data.sso_url
return SAMLIdentityProvider(self.idp_slug, **conf)
class SAMLConfiguration(ConfigurationModel):
"""
General configuration required for this edX instance to act as a SAML
Service Provider and allow users to authenticate via third party SAML
Identity Providers (IdPs)
"""
private_key = models.TextField(
help_text=(
'To generate a key pair as two files, run '
'"openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key". '
'Paste the contents of saml.key here.'
)
)
public_key = models.TextField(help_text="Public key certificate.")
entity_id = models.CharField(max_length=255, default="http://saml.example.com", verbose_name="Entity ID")
org_info_str = models.TextField(
verbose_name="Organization Info",
default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}',
help_text="JSON dictionary of 'url', 'displayname', and 'name' for each language",
)
other_config_str = models.TextField(
default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}',
help_text=(
"JSON object defining advanced settings that are passed on to python-saml. "
"Valid keys that can be set here include: SECURITY_CONFIG and SP_EXTRA"
),
)
class Meta(object): # pylint: disable=missing-docstring
verbose_name = "SAML Configuration"
verbose_name_plural = verbose_name
def clean(self):
""" Standardize and validate fields """
super(SAMLConfiguration, self).clean()
self.org_info_str = clean_json(self.org_info_str, dict)
self.other_config_str = clean_json(self.other_config_str, dict)
self.private_key = (
self.private_key
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END RSA PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.strip()
)
self.public_key = (
self.public_key
.replace("-----BEGIN CERTIFICATE-----", "")
.replace("-----END CERTIFICATE-----", "")
.strip()
)
def get_setting(self, name):
""" Get the value of a setting, or raise KeyError """
if name == "ORG_INFO":
return json.loads(self.org_info_str)
if name == "SP_ENTITY_ID":
return self.entity_id
if name == "SP_PUBLIC_CERT":
return self.public_key
if name == "SP_PRIVATE_KEY":
return self.private_key
if name == "TECHNICAL_CONTACT":
return {"givenName": "Technical Support", "emailAddress": settings.TECH_SUPPORT_EMAIL}
if name == "SUPPORT_CONTACT":
return {"givenName": "SAML Support", "emailAddress": settings.TECH_SUPPORT_EMAIL}
other_config = json.loads(self.other_config_str)
return other_config[name] # SECURITY_CONFIG, SP_EXTRA, or similar extra settings
class SAMLProviderData(models.Model):
"""
Data about a SAML IdP that is fetched automatically by 'manage.py saml pull'
This data is only required during the actual authentication process.
"""
cache_timeout = 600
fetched_at = models.DateTimeField(db_index=True, null=False)
expires_at = models.DateTimeField(db_index=True, null=True)
entity_id = models.CharField(max_length=255, db_index=True) # This is the key for lookups in this table
sso_url = models.URLField(verbose_name="SSO URL")
public_key = models.TextField()
class Meta(object): # pylint: disable=missing-docstring
verbose_name = "SAML Provider Data"
verbose_name_plural = verbose_name
ordering = ('-fetched_at', )
def is_valid(self):
""" Is this data valid? """
if self.expires_at and timezone.now() > self.expires_at:
return False
return bool(self.entity_id and self.sso_url and self.public_key)
is_valid.boolean = True
@classmethod
def cache_key_name(cls, entity_id):
""" Return the name of the key to use to cache the current data """
return 'configuration/{}/current/{}'.format(cls.__name__, entity_id)
@classmethod
def current(cls, entity_id):
"""
Return the active data entry, if any, otherwise None
"""
cached = cache.get(cls.cache_key_name(entity_id))
if cached is not None:
return cached
try:
current = cls.objects.filter(entity_id=entity_id).order_by('-fetched_at')[0]
except IndexError:
current = None
cache.set(cls.cache_key_name(entity_id), current, cls.cache_timeout)
return current

View File

@@ -61,7 +61,6 @@ import random
import string # pylint: disable-msg=deprecated-module
from collections import OrderedDict
import urllib
from ipware.ip import get_ip
import analytics
from eventtracking import tracker
@@ -197,9 +196,11 @@ class ProviderUserState(object):
lms/templates/dashboard.html.
"""
def __init__(self, enabled_provider, user, state):
def __init__(self, enabled_provider, user, association_id=None):
# UserSocialAuth row ID
self.association_id = association_id
# Boolean. Whether the user has an account associated with the provider
self.has_account = state
self.has_account = association_id is not None
# provider.BaseProvider child. Callers must verify that the provider is
# enabled.
self.provider = enabled_provider
@@ -208,7 +209,7 @@ class ProviderUserState(object):
def get_unlink_form_name(self):
"""Gets the name used in HTML forms that unlink a provider account."""
return self.provider.NAME + '_unlink_form'
return self.provider.provider_id + '_unlink_form'
def get(request):
@@ -216,7 +217,7 @@ def get(request):
return request.session.get('partial_pipeline')
def get_authenticated_user(username, backend_name):
def get_authenticated_user(auth_provider, username, uid):
"""Gets a saved user authenticated by a particular backend.
Between pipeline steps User objects are not saved. We need to reconstitute
@@ -225,43 +226,45 @@ def get_authenticated_user(username, backend_name):
authenticate().
Args:
auth_provider: the third_party_auth provider in use for the current pipeline.
username: string. Username of user to get.
backend_name: string. The name of the third-party auth backend from
the running pipeline.
uid: string. The user ID according to the third party.
Returns:
User if user is found and has a social auth from the passed
backend_name.
provider.
Raises:
User.DoesNotExist: if no user matching user is found, or the matching
user has no social auth associated with the given backend.
AssertionError: if the user is not authenticated.
"""
user = models.DjangoStorage.user.user_model().objects.get(username=username)
match = models.DjangoStorage.user.get_social_auth_for_user(user, provider=backend_name)
match = models.DjangoStorage.user.get_social_auth(provider=auth_provider.backend_name, uid=uid)
if not match:
if not match or match.user.username != username:
raise User.DoesNotExist
user.backend = provider.Registry.get_by_backend_name(backend_name).get_authentication_backend()
user = match.user
user.backend = auth_provider.get_authentication_backend()
return user
def _get_enabled_provider_by_name(provider_name):
"""Gets an enabled provider by its NAME member or throws."""
enabled_provider = provider.Registry.get(provider_name)
def _get_enabled_provider(provider_id):
"""Gets an enabled provider by its provider_id member or throws."""
enabled_provider = provider.Registry.get(provider_id)
if not enabled_provider:
raise ValueError('Provider %s not enabled' % provider_name)
raise ValueError('Provider %s not enabled' % provider_id)
return enabled_provider
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None):
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None,
extra_params=None, url_params=None):
"""Creates a URL to hook into social auth endpoints."""
kwargs = {'backend': backend_name}
url = reverse(view_name, kwargs=kwargs)
url_params = url_params or {}
url_params['backend'] = backend_name
url = reverse(view_name, kwargs=url_params)
query_params = OrderedDict()
if auth_entry:
@@ -270,6 +273,9 @@ def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None):
if redirect_url:
query_params[AUTH_REDIRECT_KEY] = redirect_url
if extra_params:
query_params.update(extra_params)
return u"{url}?{params}".format(
url=url,
params=urllib.urlencode(query_params)
@@ -289,37 +295,40 @@ def get_complete_url(backend_name):
Raises:
ValueError: if no provider is enabled with the given backend_name.
"""
enabled_provider = provider.Registry.get_by_backend_name(backend_name)
if not enabled_provider:
if not any(provider.Registry.get_enabled_by_backend_name(backend_name)):
raise ValueError('Provider with backend %s not enabled' % backend_name)
return _get_url('social:complete', backend_name)
def get_disconnect_url(provider_name):
def get_disconnect_url(provider_id, association_id):
"""Gets URL for the endpoint that starts the disconnect pipeline.
Args:
provider_name: string. Name of the provider.BaseProvider child you want
provider_id: string identifier of the models.ProviderConfig child you want
to disconnect from.
association_id: int. Optional ID of a specific row in the UserSocialAuth
table to disconnect (useful if multiple providers use a common backend)
Returns:
String. URL that starts the disconnection pipeline.
Raises:
ValueError: if no provider is enabled with the given backend_name.
ValueError: if no provider is enabled with the given ID.
"""
enabled_provider = _get_enabled_provider_by_name(provider_name)
return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name)
backend_name = _get_enabled_provider(provider_id).backend_name
if association_id:
return _get_url('social:disconnect_individual', backend_name, url_params={'association_id': association_id})
else:
return _get_url('social:disconnect', backend_name)
def get_login_url(provider_name, auth_entry, redirect_url=None):
def get_login_url(provider_id, auth_entry, redirect_url=None):
"""Gets the login URL for the endpoint that kicks off auth with a provider.
Args:
provider_name: string. The name of the provider.Provider that has been
enabled.
provider_id: string identifier of the models.ProviderConfig child you want
to disconnect from.
auth_entry: string. Query argument specifying the desired entry point
for the auth pipeline. Used by the pipeline for later branching.
Must be one of _AUTH_ENTRY_CHOICES.
@@ -332,15 +341,16 @@ def get_login_url(provider_name, auth_entry, redirect_url=None):
String. URL that starts the auth pipeline for a provider.
Raises:
ValueError: if no provider is enabled with the given provider_name.
ValueError: if no provider is enabled with the given provider_id.
"""
assert auth_entry in _AUTH_ENTRY_CHOICES
enabled_provider = _get_enabled_provider_by_name(provider_name)
enabled_provider = _get_enabled_provider(provider_id)
return _get_url(
'social:begin',
enabled_provider.BACKEND_CLASS.name,
enabled_provider.backend_name,
auth_entry=auth_entry,
redirect_url=redirect_url,
extra_params=enabled_provider.get_url_params(),
)
@@ -356,7 +366,7 @@ def get_duplicate_provider(messages):
unfortunately not in a reusable constant.
Returns:
provider.BaseProvider child instance. The provider of the duplicate
string name of the python-social-auth backend that has the duplicate
account, or None if there is no duplicate (and hence no error).
"""
social_auth_messages = [m for m in messages if m.message.endswith('is already in use.')]
@@ -365,7 +375,8 @@ def get_duplicate_provider(messages):
return
assert len(social_auth_messages) == 1
return provider.Registry.get_by_backend_name(social_auth_messages[0].extra_tags.split()[1])
backend_name = social_auth_messages[0].extra_tags.split()[1]
return backend_name
def get_provider_user_states(user):
@@ -379,13 +390,16 @@ def get_provider_user_states(user):
each enabled provider.
"""
states = []
found_user_backends = [
social_auth.provider for social_auth in models.DjangoStorage.user.get_social_auth_for_user(user)
]
found_user_auths = list(models.DjangoStorage.user.get_social_auth_for_user(user))
for enabled_provider in provider.Registry.enabled():
association_id = None
for auth in found_user_auths:
if enabled_provider.match_social_auth(auth):
association_id = auth.id
break
states.append(
ProviderUserState(enabled_provider, user, enabled_provider.BACKEND_CLASS.name in found_user_backends)
ProviderUserState(enabled_provider, user, association_id)
)
return states
@@ -489,12 +503,19 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
"""Redirects to the registration page."""
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER])
def should_force_account_creation():
""" For some third party providers, we auto-create user accounts """
current_provider = provider.Registry.get_from_pipeline({'backend': backend.name, 'kwargs': kwargs})
return current_provider and current_provider.skip_email_verification
if not user:
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
return HttpResponseBadRequest()
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
# User has authenticated with the third party provider but we don't know which edX
# account corresponds to them yet, if any.
if should_force_account_creation():
return dispatch_to_register()
return dispatch_to_login()
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]:
# User has authenticated with the third party provider and now wants to finish
@@ -534,7 +555,7 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
@partial.partial
def set_logged_in_cookie(backend=None, user=None, strategy=None, auth_entry=None, *args, **kwargs):
def set_logged_in_cookies(backend=None, user=None, strategy=None, auth_entry=None, *args, **kwargs):
"""This pipeline step sets the "logged in" cookie for authenticated users.
Some installations have a marketing site front-end separate from
@@ -566,7 +587,7 @@ def set_logged_in_cookie(backend=None, user=None, strategy=None, auth_entry=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)
has_cookie = student.cookies.is_logged_in_cookie_set(request)
if not has_cookie:
try:
redirect_url = get_complete_url(backend.name)
@@ -577,7 +598,7 @@ def set_logged_in_cookie(backend=None, user=None, strategy=None, auth_entry=None
pass
else:
response = redirect(redirect_url)
return student.helpers.set_logged_in_cookie(request, response)
return student.cookies.set_logged_in_cookies(request, response, user)
@partial.partial

View File

@@ -1,234 +1,85 @@
"""Third-party auth provider definitions.
Loaded by Django's settings mechanism. Consequently, this module must not
invoke the Django armature.
"""
from social.backends import google, linkedin, facebook
_DEFAULT_ICON_CLASS = 'fa-signin'
class BaseProvider(object):
"""Abstract base class for third-party auth providers.
All providers must subclass BaseProvider -- otherwise, they cannot be put
in the provider Registry.
"""
# Class. The provider's backing social.backends.base.BaseAuth child.
BACKEND_CLASS = None
# String. Name of the FontAwesome glyph to use for sign in buttons (or the
# name of a user-supplied custom glyph that is present at runtime).
ICON_CLASS = _DEFAULT_ICON_CLASS
# String. User-facing name of the provider. Must be unique across all
# enabled providers. Will be presented in the UI.
NAME = None
# Dict of string -> object. Settings that will be merged into Django's
# settings instance. In most cases the value will be None, since real
# values are merged from .json files (foo.auth.json; foo.env.json) onto the
# settings instance during application initialization.
SETTINGS = {}
@classmethod
def get_authentication_backend(cls):
"""Gets associated Django settings.AUTHENTICATION_BACKEND string."""
return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__)
@classmethod
def get_email(cls, provider_details):
"""Gets user's email address.
Provider responses can contain arbitrary data. This method can be
overridden to extract an email address from the provider details
extracted by the social_details pipeline step.
Args:
provider_details: dict of string -> string. Data about the
user passed back by the provider.
Returns:
String or None. The user's email address, if any.
"""
return provider_details.get('email')
@classmethod
def get_name(cls, provider_details):
"""Gets user's name.
Provider responses can contain arbitrary data. This method can be
overridden to extract a full name for a user from the provider details
extracted by the social_details pipeline step.
Args:
provider_details: dict of string -> string. Data about the
user passed back by the provider.
Returns:
String or None. The user's full name, if any.
"""
return provider_details.get('fullname')
@classmethod
def get_register_form_data(cls, pipeline_kwargs):
"""Gets dict of data to display on the register form.
common.djangoapps.student.views.register_user uses this to populate the
new account creation form with values supplied by the user's chosen
provider, preventing duplicate data entry.
Args:
pipeline_kwargs: dict of string -> object. Keyword arguments
accumulated by the pipeline thus far.
Returns:
Dict of string -> string. Keys are names of form fields; values are
values for that field. Where there is no value, the empty string
must be used.
"""
# Details about the user sent back from the provider.
details = pipeline_kwargs.get('details')
# Get the username separately to take advantage of the de-duping logic
# built into the pipeline. The provider cannot de-dupe because it can't
# check the state of taken usernames in our system. Note that there is
# technically a data race between the creation of this value and the
# creation of the user object, so it is still possible for users to get
# an error on submit.
suggested_username = pipeline_kwargs.get('username')
return {
'email': cls.get_email(details) or '',
'name': cls.get_name(details) or '',
'username': suggested_username,
}
@classmethod
def merge_onto(cls, settings):
"""Merge class-level settings onto a django settings module."""
for key, value in cls.SETTINGS.iteritems():
setattr(settings, key, value)
class GoogleOauth2(BaseProvider):
"""Provider for Google's Oauth2 auth system."""
BACKEND_CLASS = google.GoogleOAuth2
ICON_CLASS = 'fa-google-plus'
NAME = 'Google'
SETTINGS = {
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': None,
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None,
}
class LinkedInOauth2(BaseProvider):
"""Provider for LinkedIn's Oauth2 auth system."""
BACKEND_CLASS = linkedin.LinkedinOAuth2
ICON_CLASS = 'fa-linkedin'
NAME = 'LinkedIn'
SETTINGS = {
'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': None,
'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None,
}
class FacebookOauth2(BaseProvider):
"""Provider for LinkedIn's Oauth2 auth system."""
BACKEND_CLASS = facebook.FacebookOAuth2
ICON_CLASS = 'fa-facebook'
NAME = 'Facebook'
SETTINGS = {
'SOCIAL_AUTH_FACEBOOK_KEY': None,
'SOCIAL_AUTH_FACEBOOK_SECRET': None,
}
Third-party auth provider configuration API.
"""
from .models import (
OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig,
_PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS
)
class Registry(object):
"""Singleton registry of third-party auth providers.
Providers must subclass BaseProvider in order to be usable in the registry.
"""
API for querying third-party auth ProviderConfig objects.
_CONFIGURED = False
_ENABLED = {}
Providers must subclass ProviderConfig in order to be usable in the registry.
"""
@classmethod
def _check_configured(cls):
"""Ensures registry is configured."""
if not cls._CONFIGURED:
raise RuntimeError('Registry not configured')
@classmethod
def _get_all(cls):
"""Gets all provider implementations loaded into the Python runtime."""
# BaseProvider does so have __subclassess__. pylint: disable-msg=no-member
return {klass.NAME: klass for klass in BaseProvider.__subclasses__()}
@classmethod
def _enable(cls, provider):
"""Enables a single provider."""
if provider.NAME in cls._ENABLED:
raise ValueError('Provider %s already enabled' % provider.NAME)
cls._ENABLED[provider.NAME] = provider
@classmethod
def configure_once(cls, provider_names):
"""Configures providers.
Args:
provider_names: list of string. The providers to configure.
Raises:
ValueError: if the registry has already been configured, or if any
of the passed provider_names does not have a corresponding
BaseProvider child implementation.
"""
if cls._CONFIGURED:
raise ValueError('Provider registry already configured')
# Flip the bit eagerly -- configure() should not be re-callable if one
# _enable call fails.
cls._CONFIGURED = True
for name in provider_names:
all_providers = cls._get_all()
if name not in all_providers:
raise ValueError('No implementation found for provider ' + name)
cls._enable(all_providers.get(name))
def _enabled_providers(cls):
""" Helper method to iterate over all providers """
for backend_name in _PSA_OAUTH2_BACKENDS:
provider = OAuth2ProviderConfig.current(backend_name)
if provider.enabled:
yield provider
if SAMLConfiguration.is_enabled():
idp_slugs = SAMLProviderConfig.key_values('idp_slug', flat=True)
for idp_slug in idp_slugs:
provider = SAMLProviderConfig.current(idp_slug)
if provider.enabled and provider.backend_name in _PSA_SAML_BACKENDS:
yield provider
@classmethod
def enabled(cls):
"""Returns list of enabled providers."""
cls._check_configured()
return sorted(cls._ENABLED.values(), key=lambda provider: provider.NAME)
return sorted(cls._enabled_providers(), key=lambda provider: provider.name)
@classmethod
def get(cls, provider_name):
"""Gets provider named provider_name string if enabled, else None."""
cls._check_configured()
return cls._ENABLED.get(provider_name)
def get(cls, provider_id):
"""Gets provider by provider_id string if enabled, else None."""
if '-' not in provider_id: # Check format - see models.py:ProviderConfig
raise ValueError("Invalid provider_id. Expect something like oa2-google")
try:
return next(provider for provider in cls._enabled_providers() if provider.provider_id == provider_id)
except StopIteration:
return None
@classmethod
def get_by_backend_name(cls, backend_name):
"""Gets provider (or None) by backend name.
def get_from_pipeline(cls, running_pipeline):
"""Gets the provider that is being used for the specified pipeline (or None).
Args:
backend_name: string. The python-social-auth
backends.base.BaseAuth.name (for example, 'google-oauth2') to
try and get a provider for.
running_pipeline: The python-social-auth pipeline being used to
authenticate a user.
Raises:
RuntimeError: if the registry has not been configured.
Returns:
An instance of ProviderConfig or None.
"""
cls._check_configured()
for enabled in cls._ENABLED.values():
if enabled.BACKEND_CLASS.name == backend_name:
for enabled in cls._enabled_providers():
if enabled.is_active_for_pipeline(running_pipeline):
return enabled
@classmethod
def _reset(cls):
"""Returns the registry to an unconfigured state; for tests only."""
cls._CONFIGURED = False
cls._ENABLED = {}
def get_enabled_by_backend_name(cls, backend_name):
"""Generator returning all enabled providers that use the specified
backend.
Example:
>>> list(get_enabled_by_backend_name("tpa-saml"))
[<SAMLProviderConfig>, <SAMLProviderConfig>]
Args:
backend_name: The name of a python-social-auth backend used by
one or more providers.
Yields:
Instances of ProviderConfig.
"""
if backend_name in _PSA_OAUTH2_BACKENDS:
provider = OAuth2ProviderConfig.current(backend_name)
if provider.enabled:
yield provider
elif backend_name in _PSA_SAML_BACKENDS and SAMLConfiguration.is_enabled():
idp_names = SAMLProviderConfig.key_values('idp_slug', flat=True)
for idp_name in idp_names:
provider = SAMLProviderConfig.current(idp_name)
if provider.backend_name == backend_name and provider.enabled:
yield provider

View File

@@ -0,0 +1,49 @@
"""
Slightly customized python-social-auth backend for SAML 2.0 support
"""
import logging
from social.backends.saml import SAMLAuth, OID_EDU_PERSON_ENTITLEMENT
from social.exceptions import AuthForbidden
log = logging.getLogger(__name__)
class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method
"""
Customized version of SAMLAuth that gets the list of IdPs from third_party_auth's list of
enabled providers.
"""
name = "tpa-saml"
def get_idp(self, idp_name):
""" Given the name of an IdP, get a SAMLIdentityProvider instance """
from .models import SAMLProviderConfig
return SAMLProviderConfig.current(idp_name).get_config()
def setting(self, name, default=None):
""" Get a setting, from SAMLConfiguration """
if not hasattr(self, '_config'):
from .models import SAMLConfiguration
self._config = SAMLConfiguration.current() # pylint: disable=attribute-defined-outside-init
if not self._config.enabled:
from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured("SAML Authentication is not enabled.")
try:
return self._config.get_setting(name)
except KeyError:
return self.strategy.setting(name, default)
def _check_entitlements(self, idp, attributes):
"""
Check if we require the presence of any specific eduPersonEntitlement.
raise AuthForbidden if the user should not be authenticated, or do nothing
to allow the login pipeline to continue.
"""
if "requiredEntitlements" in idp.conf:
entitlements = attributes.get(OID_EDU_PERSON_ENTITLEMENT, [])
for expected in idp.conf['requiredEntitlements']:
if expected not in entitlements:
log.warning(
"SAML user from IdP %s rejected due to missing eduPersonEntitlement %s", idp.name, expected)
raise AuthForbidden(self)

View File

@@ -1,51 +1,15 @@
"""Settings for the third-party auth module.
Defers configuration of settings so we can inspect the provider registry and
create settings placeholders for only those values actually needed by a given
deployment. Required by Django; consequently, this file must not invoke the
Django armature.
The flow for settings registration is:
The base settings file contains a boolean, ENABLE_THIRD_PARTY_AUTH, indicating
whether this module is enabled. Ancillary settings files (aws.py, dev.py) put
options in THIRD_PARTY_SETTINGS. startup.py probes the ENABLE_THIRD_PARTY_AUTH.
whether this module is enabled. startup.py probes the ENABLE_THIRD_PARTY_AUTH.
If true, it:
a) loads this module.
b) calls apply_settings(), passing in settings.THIRD_PARTY_AUTH.
THIRD_PARTY AUTH is a dict of the form
'THIRD_PARTY_AUTH': {
'<PROVIDER_NAME>': {
'<PROVIDER_SETTING_NAME>': '<PROVIDER_SETTING_VALUE>',
[...]
},
[...]
}
If you are using a dev settings file, your settings dict starts at the
level of <PROVIDER_NAME> and is a map of provider name string to
settings dict. If you are using an auth.json file, it should contain a
THIRD_PARTY_AUTH entry as above.
c) apply_settings() builds a list of <PROVIDER_NAMES>. These are the
enabled third party auth providers for the deployment. These are enabled
in provider.Registry, the canonical list of enabled providers.
d) then, it sets global, provider-independent settings.
e) then, it sets provider-specific settings. For each enabled provider, we
read its SETTINGS member. These are merged onto the Django settings
object. In most cases these are stubs and the real values are set from
THIRD_PARTY_AUTH. All values that are set from this dict must first be
initialized from SETTINGS. This allows us to validate the dict and
ensure that the values match expected configuration options on the
provider.
f) finally, the (key, value) pairs from the dict file are merged onto the
django settings object.
b) calls apply_settings(), passing in the Django settings
"""
from . import provider
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next']
_MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware',
@@ -53,25 +17,7 @@ _MIDDLEWARE_CLASSES = (
_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
def _merge_auth_info(django_settings, auth_info):
"""Merge auth_info dict onto django_settings module."""
enabled_provider_names = []
to_merge = []
for provider_name, provider_dict in auth_info.items():
enabled_provider_names.append(provider_name)
# Merge iff all settings have been intialized.
for key in provider_dict:
if key not in dir(django_settings):
raise ValueError('Auth setting %s not initialized' % key)
to_merge.append(provider_dict)
for passed_validation in to_merge:
for key, value in passed_validation.iteritems():
setattr(django_settings, key, value)
def _set_global_settings(django_settings):
def apply_settings(django_settings):
"""Set provider-independent settings."""
# Whitelisted URL query parameters retrained in the pipeline session.
@@ -111,10 +57,13 @@ 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.set_logged_in_cookies',
'third_party_auth.pipeline.login_analytics',
)
# Required so that we can use unmodified PSA OAuth2 backends:
django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy'
# We let the user specify their email address during signup.
django_settings.SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email']
@@ -136,30 +85,3 @@ def _set_global_settings(django_settings):
'social.apps.django_app.context_processors.backends',
'social.apps.django_app.context_processors.login_redirect',
)
def _set_provider_settings(django_settings, enabled_providers, auth_info):
"""Sets provider-specific settings."""
# Must prepend here so we get called first.
django_settings.AUTHENTICATION_BACKENDS = (
tuple(enabled_provider.get_authentication_backend() for enabled_provider in enabled_providers) +
django_settings.AUTHENTICATION_BACKENDS)
# Merge settings from provider classes, and configure all placeholders.
for enabled_provider in enabled_providers:
enabled_provider.merge_onto(django_settings)
# Merge settings from <deployment>.auth.json, overwriting placeholders.
_merge_auth_info(django_settings, auth_info)
def apply_settings(auth_info, django_settings):
"""Applies settings from auth_info dict to django_settings module."""
if django_settings.FEATURES.get('ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'):
# The Dummy provider is handy for testing and development.
from .dummy import DummyProvider # pylint: disable=unused-variable
provider_names = auth_info.keys()
provider.Registry.configure_once(provider_names)
enabled_providers = provider.Registry.enabled()
_set_global_settings(django_settings)
_set_provider_settings(django_settings, enabled_providers, auth_info)

View File

@@ -0,0 +1,34 @@
"""
A custom Strategy for python-social-auth that allows us to fetch configuration from
ConfigurationModels rather than django.settings
"""
from .models import OAuth2ProviderConfig
from social.backends.oauth import BaseOAuth2
from social.strategies.django_strategy import DjangoStrategy
class ConfigurationModelStrategy(DjangoStrategy):
"""
A DjangoStrategy customized to load settings from ConfigurationModels
for upstream python-social-auth backends that we cannot otherwise modify.
"""
def setting(self, name, default=None, backend=None):
"""
Load the setting from a ConfigurationModel if possible, or fall back to the normal
Django settings lookup.
BaseOAuth2 subclasses will call this method for every setting they want to look up.
SAMLAuthBackend subclasses will call this method only after first checking if the
setting 'name' is configured via SAMLProviderConfig.
"""
if isinstance(backend, BaseOAuth2):
provider_config = OAuth2ProviderConfig.current(backend.name)
if not provider_config.enabled:
raise Exception("Can't fetch setting of a disabled backend/provider.")
try:
return provider_config.get_setting(name)
except KeyError:
pass
# At this point, we know 'name' is not set in a [OAuth2|SAML]ProviderConfig row.
# It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION':
return super(ConfigurationModelStrategy, self).setting(name, default, backend)

View File

@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
"""
Code to manage fetching and storing the metadata of IdPs.
"""
#pylint: disable=no-member
from celery.task import task # pylint: disable=import-error,no-name-in-module
import datetime
import dateutil.parser
import logging
from lxml import etree
import requests
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from third_party_auth.models import SAMLConfiguration, SAMLProviderConfig, SAMLProviderData
log = logging.getLogger(__name__)
SAML_XML_NS = 'urn:oasis:names:tc:SAML:2.0:metadata' # The SAML Metadata XML namespace
class MetadataParseError(Exception):
""" An error occurred while parsing the SAML metadata from an IdP """
pass
@task(name='third_party_auth.fetch_saml_metadata')
def fetch_saml_metadata():
"""
Fetch and store/update the metadata of all IdPs
This task should be run on a daily basis.
It's OK to run this whether or not SAML is enabled.
Return value:
tuple(num_changed, num_failed, num_total)
num_changed: Number of providers that are either new or whose metadata has changed
num_failed: Number of providers that could not be updated
num_total: Total number of providers whose metadata was fetched
"""
if not SAMLConfiguration.is_enabled():
return (0, 0, 0) # Nothing to do until SAML is enabled.
num_changed, num_failed = 0, 0
# First make a list of all the metadata XML URLs:
url_map = {}
for idp_slug in SAMLProviderConfig.key_values('idp_slug', flat=True):
config = SAMLProviderConfig.current(idp_slug)
if not config.enabled:
continue
url = config.metadata_source
if url not in url_map:
url_map[url] = []
if config.entity_id not in url_map[url]:
url_map[url].append(config.entity_id)
# Now fetch the metadata:
for url, entity_ids in url_map.items():
try:
log.info("Fetching %s", url)
if not url.lower().startswith('https'):
log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url)
response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError
response.raise_for_status() # May raise an HTTPError
try:
parser = etree.XMLParser(remove_comments=True)
xml = etree.fromstring(response.text, parser)
except etree.XMLSyntaxError:
raise
# TODO: Can use OneLogin_Saml2_Utils to validate signed XML if anyone is using that
for entity_id in entity_ids:
log.info(u"Processing IdP with entityID %s", entity_id)
public_key, sso_url, expires_at = _parse_metadata_xml(xml, entity_id)
changed = _update_data(entity_id, public_key, sso_url, expires_at)
if changed:
log.info(u"→ Created new record for SAMLProviderData")
num_changed += 1
else:
log.info(u"→ Updated existing SAMLProviderData. Nothing has changed.")
except Exception as err: # pylint: disable=broad-except
log.exception(err.message)
num_failed += 1
return (num_changed, num_failed, len(url_map))
def _parse_metadata_xml(xml, entity_id):
"""
Given an XML document containing SAML 2.0 metadata, parse it and return a tuple of
(public_key, sso_url, expires_at) for the specified entityID.
Raises MetadataParseError if anything is wrong.
"""
if xml.tag == etree.QName(SAML_XML_NS, 'EntityDescriptor'):
entity_desc = xml
else:
if xml.tag != etree.QName(SAML_XML_NS, 'EntitiesDescriptor'):
raise MetadataParseError("Expected root element to be <EntitiesDescriptor>, not {}".format(xml.tag))
entity_desc = xml.find(
".//{}[@entityID='{}']".format(etree.QName(SAML_XML_NS, 'EntityDescriptor'), entity_id)
)
if not entity_desc:
raise MetadataParseError("Can't find EntityDescriptor for entityID {}".format(entity_id))
expires_at = None
if "validUntil" in xml.attrib:
expires_at = dateutil.parser.parse(xml.attrib["validUntil"])
if "cacheDuration" in xml.attrib:
cache_expires = OneLogin_Saml2_Utils.parse_duration(xml.attrib["cacheDuration"])
if expires_at is None or cache_expires < expires_at:
expires_at = cache_expires
sso_desc = entity_desc.find(etree.QName(SAML_XML_NS, "IDPSSODescriptor"))
if not sso_desc:
raise MetadataParseError("IDPSSODescriptor missing")
if 'urn:oasis:names:tc:SAML:2.0:protocol' not in sso_desc.get("protocolSupportEnumeration"):
raise MetadataParseError("This IdP does not support SAML 2.0")
# Now we just need to get the public_key and sso_url
public_key = sso_desc.findtext("./{}//{}".format(
etree.QName(SAML_XML_NS, "KeyDescriptor"), "{http://www.w3.org/2000/09/xmldsig#}X509Certificate"
))
if not public_key:
raise MetadataParseError("Public Key missing. Expected an <X509Certificate>")
public_key = public_key.replace(" ", "")
binding_elements = sso_desc.iterfind("./{}".format(etree.QName(SAML_XML_NS, "SingleSignOnService")))
sso_bindings = {element.get('Binding'): element.get('Location') for element in binding_elements}
try:
# The only binding supported by python-saml and python-social-auth is HTTP-Redirect:
sso_url = sso_bindings['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']
except KeyError:
raise MetadataParseError("Unable to find SSO URL with HTTP-Redirect binding.")
return public_key, sso_url, expires_at
def _update_data(entity_id, public_key, sso_url, expires_at):
"""
Update/Create the SAMLProviderData for the given entity ID.
Return value:
False if nothing has changed and existing data's "fetched at" timestamp is just updated.
True if a new record was created. (Either this is a new provider or something changed.)
"""
data_obj = SAMLProviderData.current(entity_id)
fetched_at = datetime.datetime.now()
if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url):
data_obj.expires_at = expires_at
data_obj.fetched_at = fetched_at
data_obj.save()
return False
else:
SAMLProviderData.objects.create(
entity_id=entity_id,
fetched_at=fetched_at,
expires_at=expires_at,
sso_url=sso_url,
public_key=public_key,
)
return True

View File

@@ -0,0 +1,15 @@
-----BEGIN RSA PRIVATE KEY-----
MIICWwIBAAKBgQDM+Nf7IeRdIIgYUke6sR3n7osHVYXwH6pb+Ovq8j3hUoy8kzT9
kJF0RB3h3Q2VJ3ZWiQtT94fZX2YYorVdoGVK2NWzjLwgpHUsgfeJq5pCjP0d2OQu
9Qvjg6YOtYP6PN3j7eK7pUcxQvIcaY9APDF57ua/zPsm3UzbjhRlJZQUewIDAQAB
AoGADWBsD/qdQaqe1x9/iOKINhuuPRNKw2n9nzT2iIW4nhzaDHB689VceL79SEE5
4rMJmQomkBtGZVxBeHgd5/dQxNy3bC9lPN1uoMuzjQs7UMk+lvy0MoHfiJcuIxPX
RdyZTV9LKN8vq+ZpVykVu6pBdDlne4psPZeQ76ynxke/24ECQQD3NX7JeluZ64la
tC6b3VHzA4Hd1qTXDWtEekh2WaR2xuKzcLyOWhqPIWprylBqVc1m+FA/LRRWQ9y6
vJMiXMk7AkEA1ELWj9DtZzk9BV1JxsDUUP0/IMAiYliVac3YrvQfys8APCY1xr9q
BAGurH4VWXuEnbx1yNXK89HqFI7kDrMtwQJAVTXtVAmHFZEosUk2X6d0He3xj8Py
4eXQObRk0daoaAC6F9weQnsweHGuOyVrfpvAx2OEVaJ2Rh3yMbPai5esDQJAS9Yh
gLqdx26M3bjJ3igQ82q3vkTHRCnwICA6la+FGFnC9LqWJg9HmmzbcqeNiy31YMHv
tzSjUV+jaXrwAkyEQQJAK/SCIVsWRhFe/ssr8hS//V+hZC4kvCv4b3NqzZK1x+Xm
7GaGMV0xEWN7shqVSRBU4O2vn/RWD6/6x3sHkU57qg==
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICsDCCAhmgAwIBAgIJAJrENr8EPgpcMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTUwNjEzMDEwNTE0WhcNMjUwNjEyMDEwNTE0WjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB
gQDM+Nf7IeRdIIgYUke6sR3n7osHVYXwH6pb+Ovq8j3hUoy8kzT9kJF0RB3h3Q2V
J3ZWiQtT94fZX2YYorVdoGVK2NWzjLwgpHUsgfeJq5pCjP0d2OQu9Qvjg6YOtYP6
PN3j7eK7pUcxQvIcaY9APDF57ua/zPsm3UzbjhRlJZQUewIDAQABo4GnMIGkMB0G
A1UdDgQWBBTjOyPvAuej5q4C80jlFrQmOlszmzB1BgNVHSMEbjBsgBTjOyPvAuej
5q4C80jlFrQmOlszm6FJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt
U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAJrENr8E
PgpcMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAV5w0SxjUTFWfL3ZG
6sgA0gKf8aV8w3AlihLt9tKCRgrK4sBK9xmfwp/fnbdxkHU58iozI894HqmrRzCi
aRLWmy3W8640E/XCa6P+i8ET7RksgNJ5cD9WtISHkGc2dnW76+2nv8d24JKeIx2w
oJAtspMywzr0SoxDIJr42N6Kvjk=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,16 @@
-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMoR8CP+HlvsPRwi
VCCuWxZOdNjYa4Qre3JEWPkqlUwpci1XGTBqH7DK9b2hmBXMjYoDKOnF5pL7Y453
3JSJ2+AG7D4AJGSotA3boKF18EDgeMzAWjAhDVhTprGz+/1G+W0R4SSyY5QGyBhL
Z36xF2w5HyeiqN/Iiq3QKGl2CFORAgMBAAECgYEAwH2CAudqSCqstAZHmbI99uva
B09ybD93owxUrVbRTfIVX/eeeS4+7g0JNxGebPWkxxnneXoaAV4UIn0v1RfWKMs3
QGiBsOSup1DWWwkBfvQ1hNlJfVCqgZH1QVbhPpw9M9gxhLZQaSZoI/qY/8n/54L0
zU4S6VYBH6hnkgZZmiECQQDpYUS8HgnkMUX/qcDNBJT23qHewHsZOe6uqC+7+YxQ
xKT8iCxybDbZU7hmZ1Av8Ns4iF7EvZ0faFM8Ls76wFX1AkEA3afLUMLHfTx40XwO
oU7GWrYFyLNCc3/7JeWi6ZKzwzQqiGvFderRf/QGQsCtpLQ8VoLz/knF9TkQdSh6
yuIprQJATfcmxUmruEYVwnFtbZBoS4jYvtfCyAyohkS9naiijaEEFTFQ1/D66eOk
KOG+0iU+t0YnksZdpU5u8B4bG34BuQJAXv6FhTQk+MhM40KupnUzTzcJXY1t4kAs
K36yBjZoMjWOMO83LiUX6iVz9XHMOXVBEraGySlm3IS7R+q0TXUF9QJAQ69wautf
8q1OQiLcg5WTFmSFBEXqAvVwX6FcDSxor9UnI0iHwyKBss3a2IXY9LoTPTjR5SHh
GDq2lXmP+kmbnQ==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICWDCCAcGgAwIBAgIJAMlM2wrOvplkMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTUwNjEzMDEyMTAwWhcNMjUwNjEyMDEyMTAwWjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB
gQDKEfAj/h5b7D0cIlQgrlsWTnTY2GuEK3tyRFj5KpVMKXItVxkwah+wyvW9oZgV
zI2KAyjpxeaS+2OOd9yUidvgBuw+ACRkqLQN26ChdfBA4HjMwFowIQ1YU6axs/v9
RvltEeEksmOUBsgYS2d+sRdsOR8noqjfyIqt0ChpdghTkQIDAQABo1AwTjAdBgNV
HQ4EFgQUU0TNPc1yGas/W4HJl/Hgtrmdu6MwHwYDVR0jBBgwFoAUU0TNPc1yGas/
W4HJl/Hgtrmdu6MwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQCE4BqJ
v2s99DS16NbZtR7tpqXDxiDaCg59VtgcHQwxN4qXcixZi5N4yRvzjYschAQN5tQ6
bofXdIK3tJY9Ynm0KPO+5l0RCv7CkhNgftTww0bWC91xaHJ/y66AqONuLpaP6s43
SZYG2D6ric57ZY4kQ6ZlUv854TPzmvapnGG7Hw==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,155 @@
<!-- Cached and simplified copy of https://www.testshib.org/metadata/testshib-providers.xml -->
<EntitiesDescriptor Name="urn:mace:shibboleth:testshib:two"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
xmlns:mdalg="urn:oasis:names:tc:SAML:metadata:algsupport" xmlns:mdui="urn:oasis:names:tc:SAML:metadata:ui"
xmlns:shibmd="urn:mace:shibboleth:metadata:1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<EntityDescriptor entityID="https://idp.testshib.org/idp/shibboleth">
<Extensions>
<mdalg:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha512" />
<mdalg:DigestMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#sha384" />
<mdalg:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<mdalg:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
<mdalg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" />
<mdalg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" />
<mdalg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
<mdalg:SigningMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
</Extensions>
<IDPSSODescriptor
protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:mace:shibboleth:1.0 urn:oasis:names:tc:SAML:2.0:protocol">
<Extensions>
<shibmd:Scope regexp="false">testshib.org</shibmd:Scope>
<mdui:UIInfo>
<mdui:DisplayName xml:lang="en">TestShib Test IdP</mdui:DisplayName>
<mdui:Description xml:lang="en">TestShib IdP. Use this as a source of attributes
for your test SP.</mdui:Description>
<mdui:Logo height="88" width="253"
>https://www.testshib.org/testshibtwo.jpg</mdui:Logo>
</mdui:UIInfo>
</Extensions>
<KeyDescriptor>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>
MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV
MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD
VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4
MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI
EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl
c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B
AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C
yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe
3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT
NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614
kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH
gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G
A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86
9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl
bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo
aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN
BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL
I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo
93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4
/SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj
Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr
8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA==
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes192-cbc" />
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#tripledes-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
</KeyDescriptor>
<ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:1.0:bindings:SOAP-binding"
Location="https://idp.testshib.org:8443/idp/profile/SAML1/SOAP/ArtifactResolution"
index="1"/>
<ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
Location="https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/ArtifactResolution"
index="2"/>
<NameIDFormat>urn:mace:shibboleth:1.0:nameIdentifier</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
<SingleSignOnService Binding="urn:mace:shibboleth:1.0:profiles:AuthnRequest"
Location="https://idp.testshib.org/idp/profile/Shibboleth/SSO"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://idp.testshib.org/idp/profile/SAML2/POST/SSO"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
Location="https://idp.testshib.org/idp/profile/SAML2/SOAP/ECP"/>
</IDPSSODescriptor>
<AttributeAuthorityDescriptor
protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>
MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV
MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD
VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4
MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI
EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl
c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B
AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C
yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe
3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT
NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614
kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH
gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G
A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86
9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl
bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo
aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN
BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL
I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo
93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4
/SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj
Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr
8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA==
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes192-cbc" />
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#tripledes-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
</KeyDescriptor>
<AttributeService Binding="urn:oasis:names:tc:SAML:1.0:bindings:SOAP-binding"
Location="https://idp.testshib.org:8443/idp/profile/SAML1/SOAP/AttributeQuery"/>
<AttributeService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
Location="https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/AttributeQuery"/>
<NameIDFormat>urn:mace:shibboleth:1.0:nameIdentifier</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
</AttributeAuthorityDescriptor>
<Organization>
<OrganizationName xml:lang="en">TestShib Two Identity Provider</OrganizationName>
<OrganizationDisplayName xml:lang="en">TestShib Two</OrganizationDisplayName>
<OrganizationURL xml:lang="en">http://www.testshib.org/testshib-two/</OrganizationURL>
</Organization>
<ContactPerson contactType="technical">
<GivenName>Nate</GivenName>
<SurName>Klingenstein</SurName>
<EmailAddress>ndk@internet2.edu</EmailAddress>
</ContactPerson>
</EntityDescriptor>
</EntitiesDescriptor>

File diff suppressed because one or more lines are too long

View File

@@ -32,15 +32,8 @@ from third_party_auth.tests import testutil
class IntegrationTest(testutil.TestCase, test.TestCase):
"""Abstract base class for provider integration tests."""
# Configuration. You will need to override these values in your test cases.
# Class. The third_party_auth.provider.BaseProvider child we are testing.
PROVIDER_CLASS = None
# Dict of string -> object. Settings that will be merged onto Django's
# settings object before test execution. In most cases, this is
# PROVIDER_CLASS.SETTINGS with test values.
PROVIDER_SETTINGS = {}
# Override setUp and set this:
provider = None
# Methods you must override in your children.
@@ -94,10 +87,10 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
"""
self.assertEqual(200, response.status_code)
# Check that the correct provider was selected.
self.assertIn('successfully signed in with <strong>%s</strong>' % self.PROVIDER_CLASS.NAME, response.content)
self.assertIn('successfully signed in with <strong>%s</strong>' % self.provider.name, response.content)
# Expect that each truthy value we've prepopulated the register form
# with is actually present.
for prepopulated_form_value in self.PROVIDER_CLASS.get_register_form_data(pipeline_kwargs).values():
for prepopulated_form_value in self.provider.get_register_form_data(pipeline_kwargs).values():
if prepopulated_form_value:
self.assertIn(prepopulated_form_value, response.content)
@@ -106,27 +99,30 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def setUp(self):
super(IntegrationTest, self).setUp()
self.configure_runtime()
self.backend_name = self.PROVIDER_CLASS.BACKEND_CLASS.name
self.client = test.Client()
self.request_factory = test.RequestFactory()
def assert_account_settings_context_looks_correct(self, context, user, duplicate=False, linked=None):
@property
def backend_name(self):
""" Shortcut for the backend name """
return self.provider.backend_name
# pylint: disable=invalid-name
def assert_account_settings_context_looks_correct(self, context, _user, duplicate=False, linked=None):
"""Asserts the user's account settings page context is in the expected state.
If duplicate is True, we expect context['duplicate_provider'] to contain
the duplicate provider object. If linked is passed, we conditionally
the duplicate provider backend name. If linked is passed, we conditionally
check that the provider is included in context['auth']['providers'] and
its connected state is correct.
"""
if duplicate:
self.assertEqual(context['duplicate_provider'].NAME, self.PROVIDER_CLASS.NAME)
self.assertEqual(context['duplicate_provider'], self.provider.backend_name)
else:
self.assertIsNone(context['duplicate_provider'])
if linked is not None:
expected_provider = [
provider for provider in context['auth']['providers'] if provider['name'] == self.PROVIDER_CLASS.NAME
provider for provider in context['auth']['providers'] if provider['name'] == self.provider.name
][0]
self.assertIsNotNone(expected_provider)
self.assertEqual(expected_provider['connected'], linked)
@@ -197,7 +193,10 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def assert_json_failure_response_is_missing_social_auth(self, response):
"""Asserts failure on /login for missing social auth looks right."""
self.assertEqual(403, response.status_code)
self.assertIn("successfully logged into your %s account, but this account isn't linked" % self.PROVIDER_CLASS.NAME, response.content)
self.assertIn(
"successfully logged into your %s account, but this account isn't linked" % self.provider.name,
response.content
)
def assert_json_failure_response_is_username_collision(self, response):
"""Asserts the json response indicates a username collision."""
@@ -211,7 +210,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assertEqual(200, response.status_code)
payload = json.loads(response.content)
self.assertTrue(payload.get('success'))
self.assertEqual(pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name), payload.get('redirect_url'))
self.assertEqual(pipeline.get_complete_url(self.provider.backend_name), payload.get('redirect_url'))
def assert_login_response_before_pipeline_looks_correct(self, response):
"""Asserts a GET of /login not in the pipeline looks correct."""
@@ -219,7 +218,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# The combined login/registration page dynamically generates the login button,
# but we can still check that the provider name is passed in the data attribute
# for the container element.
self.assertIn(self.PROVIDER_CLASS.NAME, response.content)
self.assertIn(self.provider.name, response.content)
def assert_login_response_in_pipeline_looks_correct(self, response):
"""Asserts a GET of /login in the pipeline looks correct."""
@@ -258,28 +257,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# The combined login/registration page dynamically generates the register button,
# but we can still check that the provider name is passed in the data attribute
# for the container element.
self.assertIn(self.PROVIDER_CLASS.NAME, response.content)
self.assertIn(self.provider.name, response.content)
def assert_social_auth_does_not_exist_for_user(self, user, strategy):
"""Asserts a user does not have an auth with the expected provider."""
social_auths = strategy.storage.user.get_social_auth_for_user(
user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name)
user, provider=self.provider.backend_name)
self.assertEqual(0, len(social_auths))
def assert_social_auth_exists_for_user(self, user, strategy):
"""Asserts a user has a social auth with the expected provider."""
social_auths = strategy.storage.user.get_social_auth_for_user(
user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name)
user, provider=self.provider.backend_name)
self.assertEqual(1, len(social_auths))
self.assertEqual(self.backend_name, social_auths[0].provider)
def configure_runtime(self):
"""Configures settings details."""
auth_settings.apply_settings({self.PROVIDER_CLASS.NAME: self.PROVIDER_SETTINGS}, django_settings)
# Force settings to propagate into cached members on
# social.apps.django_app.utils.
reload(social_utils)
def create_user_models_for_existing_account(self, strategy, email, password, username, skip_social_auth=False):
"""Creates user, profile, registration, and (usually) social auth.
@@ -296,7 +288,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
registration.save()
if not skip_social_auth:
social_utils.Storage.user.create_social_auth(user, uid, self.PROVIDER_CLASS.BACKEND_CLASS.name)
social_utils.Storage.user.create_social_auth(user, uid, self.provider.backend_name)
return user
@@ -370,13 +362,17 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(
response["Location"],
pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name)
pipeline.get_complete_url(self.provider.backend_name)
)
self.assertEqual(response.cookies[django_settings.EDXMKTG_COOKIE_NAME].value, 'true')
self.assertEqual(response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value, 'true')
self.assertIn(django_settings.EDXMKTG_USER_INFO_COOKIE_NAME, response.cookies)
def set_logged_in_cookie(self, request):
def set_logged_in_cookies(self, request):
"""Simulate setting the marketing site cookie on the request. """
request.COOKIES[django_settings.EDXMKTG_COOKIE_NAME] = 'true'
request.COOKIES[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] = 'true'
request.COOKIES[django_settings.EDXMKTG_USER_INFO_COOKIE_NAME] = json.dumps({
'version': django_settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
})
# Actual tests, executed once per child.
@@ -413,7 +409,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Instrument the pipeline to get to the dashboard with the full
# expected state.
self.client.get(
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
mako_middleware_process_request(strategy.request)
@@ -434,7 +430,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
))
# Set the cookie and try again
self.set_logged_in_cookie(request)
self.set_logged_in_cookies(request)
# Fire off the auth pipeline to link.
self.assert_redirect_to_dashboard_looks_correct(actions.do_complete(
@@ -456,12 +452,12 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
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)
self.set_logged_in_cookies(request)
# Instrument the pipeline to get to the dashboard with the full
# expected state.
self.client.get(
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
mako_middleware_process_request(strategy.request)
@@ -520,7 +516,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assert_social_auth_exists_for_user(user, strategy)
self.client.get('/login')
self.client.get(pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
mako_middleware_process_request(strategy.request)
@@ -532,7 +528,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
request._messages = fallback.FallbackStorage(request)
middleware.ExceptionMiddleware().process_exception(
request,
exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'account is already in use.'))
exceptions.AuthAlreadyAssociated(self.provider.backend_name, 'account is already in use.'))
self.assert_account_settings_context_looks_correct(
account_settings_context(request), user, duplicate=True, linked=True)
@@ -557,7 +553,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Synthesize that request and check that it redirects to the correct
# provider page.
self.assert_redirect_to_provider_looks_correct(self.client.get(
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)))
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)))
# Next, the provider makes a request against /auth/complete/<provider>
# to resume the pipeline.
@@ -582,7 +578,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
))
# Set the cookie and try again
self.set_logged_in_cookie(request)
self.set_logged_in_cookies(request)
self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(request.backend, social_views._do_login, user=user))
@@ -637,7 +633,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Synthesize that request and check that it redirects to the correct
# provider page.
self.assert_redirect_to_provider_looks_correct(self.client.get(
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)))
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)))
# Next, the provider makes a request against /auth/complete/<provider>.
# pylint: disable=protected-access
@@ -683,7 +679,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
))
# Set the cookie and try again
self.set_logged_in_cookie(request)
self.set_logged_in_cookies(request)
self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(strategy.request.backend, social_views._do_login, user=created_user))
# Now the user has been redirected to the dashboard. Their third party account should now be linked.

View File

@@ -7,11 +7,14 @@ from third_party_auth.tests.specs import base
class GoogleOauth2IntegrationTest(base.Oauth2IntegrationTest):
"""Integration tests for provider.GoogleOauth2."""
PROVIDER_CLASS = provider.GoogleOauth2
PROVIDER_SETTINGS = {
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key',
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': 'google_oauth2_secret',
}
def setUp(self):
super(GoogleOauth2IntegrationTest, self).setUp()
self.provider = self.configure_google_provider(
enabled=True,
key='google_oauth2_key',
secret='google_oauth2_secret',
)
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',

View File

@@ -7,11 +7,14 @@ from third_party_auth.tests.specs import base
class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest):
"""Integration tests for provider.LinkedInOauth2."""
PROVIDER_CLASS = provider.LinkedInOauth2
PROVIDER_SETTINGS = {
'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_oauth2_key',
'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': 'linkedin_oauth2_secret',
}
def setUp(self):
super(LinkedInOauth2IntegrationTest, self).setUp()
self.provider = self.configure_linkedin_provider(
enabled=True,
key='linkedin_oauth2_key',
secret='linkedin_oauth2_secret',
)
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',

View File

@@ -0,0 +1,230 @@
"""
Third_party_auth integration tests using a mock version of the TestShib provider
"""
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
import httpretty
from mock import patch
from student.tests.factories import UserFactory
from third_party_auth.tasks import fetch_saml_metadata
from third_party_auth.tests import testutil
import unittest
TESTSHIB_ENTITY_ID = 'https://idp.testshib.org/idp/shibboleth'
TESTSHIB_METADATA_URL = 'https://mock.testshib.org/metadata/testshib-providers.xml'
TESTSHIB_SSO_URL = 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO'
TPA_TESTSHIB_LOGIN_URL = '/auth/login/tpa-saml/?auth_entry=login&next=%2Fdashboard&idp=testshib'
TPA_TESTSHIB_REGISTER_URL = '/auth/login/tpa-saml/?auth_entry=register&next=%2Fdashboard&idp=testshib'
TPA_TESTSHIB_COMPLETE_URL = '/auth/complete/tpa-saml/'
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
class TestShibIntegrationTest(testutil.SAMLTestCase):
"""
TestShib provider Integration Test, to test SAML functionality
"""
def setUp(self):
super(TestShibIntegrationTest, self).setUp()
self.login_page_url = reverse('signin_user')
self.register_page_url = reverse('register_user')
self.enable_saml(
private_key=self._get_private_key(),
public_key=self._get_public_key(),
entity_id="https://saml.example.none",
)
# Mock out HTTP requests that may be made to TestShib:
httpretty.enable()
def metadata_callback(_request, _uri, headers):
""" Return a cached copy of TestShib's metadata by reading it from disk """
return (200, headers, self._read_data_file('testshib_metadata.xml'))
httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback)
self.addCleanup(httpretty.disable)
self.addCleanup(httpretty.reset)
# Configure the SAML library to use the same request ID for every request.
# Doing this and freezing the time allows us to play back recorded request/response pairs
uid_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id', return_value='TESTID')
uid_patch.start()
self.addCleanup(uid_patch.stop)
def test_login_before_metadata_fetched(self):
self._configure_testshib_provider(fetch_metadata=False)
# The user goes to the login page, and sees a button to login with TestShib:
self._check_login_page()
# The user clicks on the TestShib button:
try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
# The user should be redirected to back to the login page:
self.assertEqual(try_login_response.status_code, 302)
self.assertEqual(try_login_response['Location'], self.url_prefix + self.login_page_url)
# When loading the login page, the user will see an error message:
response = self.client.get(self.login_page_url)
self.assertEqual(response.status_code, 200)
self.assertIn('Authentication with TestShib is currently unavailable.', response.content)
def test_register(self):
self._configure_testshib_provider()
self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
# The user goes to the register page, and sees a button to register with TestShib:
self._check_register_page()
# The user clicks on the TestShib button:
try_login_response = self.client.get(TPA_TESTSHIB_REGISTER_URL)
# The user should be redirected to TestShib:
self.assertEqual(try_login_response.status_code, 302)
self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
# Now the user will authenticate with the SAML provider
testshib_response = self._fake_testshib_login_and_return()
# We should be redirected to the register screen since this account is not linked to an edX account:
self.assertEqual(testshib_response.status_code, 302)
self.assertEqual(testshib_response['Location'], self.url_prefix + self.register_page_url)
register_response = self.client.get(self.register_page_url)
# We'd now like to see if the "You've successfully signed into TestShib" message is
# shown, but it's managed by a JavaScript runtime template, and we can't run JS in this
# type of test, so we just check for the variable that triggers that message.
self.assertIn('&#34;currentProvider&#34;: &#34;TestShib&#34;', register_response.content)
self.assertIn('&#34;errorMessage&#34;: null', register_response.content)
# Now do a crude check that the data (e.g. email) from the provider is displayed in the form:
self.assertIn('&#34;defaultValue&#34;: &#34;myself@testshib.org&#34;', register_response.content)
self.assertIn('&#34;defaultValue&#34;: &#34;Me Myself And I&#34;', register_response.content)
# Now complete the form:
ajax_register_response = self.client.post(
reverse('user_api_registration'),
{
'email': 'myself@testshib.org',
'name': 'Myself',
'username': 'myself',
'honor_code': True,
}
)
self.assertEqual(ajax_register_response.status_code, 200)
# Then the AJAX will finish the third party auth:
continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
# And we should be redirected to the dashboard:
self.assertEqual(continue_response.status_code, 302)
self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
# Now check that we can login again:
self.client.logout()
self._verify_user_email('myself@testshib.org')
self._test_return_login()
def test_login(self):
self._configure_testshib_provider()
self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
user = UserFactory.create()
# The user goes to the login page, and sees a button to login with TestShib:
self._check_login_page()
# The user clicks on the TestShib button:
try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
# The user should be redirected to TestShib:
self.assertEqual(try_login_response.status_code, 302)
self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
# Now the user will authenticate with the SAML provider
testshib_response = self._fake_testshib_login_and_return()
# We should be redirected to the login screen since this account is not linked to an edX account:
self.assertEqual(testshib_response.status_code, 302)
self.assertEqual(testshib_response['Location'], self.url_prefix + self.login_page_url)
login_response = self.client.get(self.login_page_url)
# We'd now like to see if the "You've successfully signed into TestShib" message is
# shown, but it's managed by a JavaScript runtime template, and we can't run JS in this
# type of test, so we just check for the variable that triggers that message.
self.assertIn('&#34;currentProvider&#34;: &#34;TestShib&#34;', login_response.content)
self.assertIn('&#34;errorMessage&#34;: null', login_response.content)
# Now the user enters their username and password.
# The AJAX on the page will log them in:
ajax_login_response = self.client.post(
reverse('user_api_login_session'),
{'email': user.email, 'password': 'test'}
)
self.assertEqual(ajax_login_response.status_code, 200)
# Then the AJAX will finish the third party auth:
continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
# And we should be redirected to the dashboard:
self.assertEqual(continue_response.status_code, 302)
self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
# Now check that we can login again:
self.client.logout()
self._test_return_login()
def _test_return_login(self):
""" Test logging in to an account that is already linked. """
# Make sure we're not logged in:
dashboard_response = self.client.get(reverse('dashboard'))
self.assertEqual(dashboard_response.status_code, 302)
# The user goes to the login page, and sees a button to login with TestShib:
self._check_login_page()
# The user clicks on the TestShib button:
try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
# The user should be redirected to TestShib:
self.assertEqual(try_login_response.status_code, 302)
self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
# Now the user will authenticate with the SAML provider
login_response = self._fake_testshib_login_and_return()
# There will be one weird redirect required to set the login cookie:
self.assertEqual(login_response.status_code, 302)
self.assertEqual(login_response['Location'], self.url_prefix + TPA_TESTSHIB_COMPLETE_URL)
# And then we should be redirected to the dashboard:
login_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
self.assertEqual(login_response.status_code, 302)
self.assertEqual(login_response['Location'], self.url_prefix + reverse('dashboard'))
# Now we are logged in:
dashboard_response = self.client.get(reverse('dashboard'))
self.assertEqual(dashboard_response.status_code, 200)
def _freeze_time(self, timestamp):
""" Mock the current time for SAML, so we can replay canned requests/responses """
now_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.now', return_value=timestamp)
now_patch.start()
self.addCleanup(now_patch.stop)
def _check_login_page(self):
""" Load the login form and check that it contains a TestShib button """
response = self.client.get(self.login_page_url)
self.assertEqual(response.status_code, 200)
self.assertIn("TestShib", response.content)
self.assertIn(TPA_TESTSHIB_LOGIN_URL.replace('&', '&amp;'), response.content)
return response
def _check_register_page(self):
""" Load the login form and check that it contains a TestShib button """
response = self.client.get(self.register_page_url)
self.assertEqual(response.status_code, 200)
self.assertIn("TestShib", response.content)
self.assertIn(TPA_TESTSHIB_REGISTER_URL.replace('&', '&amp;'), response.content)
return response
def _configure_testshib_provider(self, **kwargs):
""" Enable and configure the TestShib SAML IdP as a third_party_auth provider """
fetch_metadata = kwargs.pop('fetch_metadata', True)
kwargs.setdefault('name', 'TestShib')
kwargs.setdefault('enabled', True)
kwargs.setdefault('idp_slug', 'testshib')
kwargs.setdefault('entity_id', TESTSHIB_ENTITY_ID)
kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL)
kwargs.setdefault('icon_class', 'fa-university')
kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName
self.configure_saml_provider(**kwargs)
if fetch_metadata:
self.assertTrue(httpretty.is_enabled())
num_changed, num_failed, num_total = fetch_saml_metadata()
self.assertEqual(num_failed, 0)
self.assertEqual(num_changed, 1)
self.assertEqual(num_total, 1)
def _fake_testshib_login_and_return(self):
""" Mocked: the user logs in to TestShib and then gets redirected back """
# The SAML provider (TestShib) will authenticate the user, then get the browser to POST a response:
return self.client.post(
TPA_TESTSHIB_COMPLETE_URL,
content_type='application/x-www-form-urlencoded',
data=self._read_data_file('testshib_response.txt'),
)
def _verify_user_email(self, email):
""" Mark the user with the given email as verified """
user = User.objects.get(email=email)
user.is_active = True
user.save()

View File

@@ -4,6 +4,7 @@ import random
from third_party_auth import pipeline, provider
from third_party_auth.tests import testutil
import unittest
# Allow tests access to protected methods (or module-protected methods) under
@@ -34,9 +35,11 @@ class MakeRandomPasswordTest(testutil.TestCase):
self.assertEqual(expected, pipeline.make_random_password(choice_fn=random_instance.choice))
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
class ProviderUserStateTestCase(testutil.TestCase):
"""Tests ProviderUserState behavior."""
def test_get_unlink_form_name(self):
state = pipeline.ProviderUserState(provider.GoogleOauth2, object(), False)
self.assertEqual(provider.GoogleOauth2.NAME + '_unlink_form', state.get_unlink_form_name())
google_provider = self.configure_google_provider(enabled=True)
state = pipeline.ProviderUserState(google_provider, object(), 1000)
self.assertEqual(google_provider.provider_id + '_unlink_form', state.get_unlink_form_name())

View File

@@ -21,9 +21,7 @@ class TestCase(testutil.TestCase, test.TestCase):
def setUp(self):
super(TestCase, self).setUp()
self.enabled_provider_name = provider.GoogleOauth2.NAME
provider.Registry.configure_once([self.enabled_provider_name])
self.enabled_provider = provider.Registry.get(self.enabled_provider_name)
self.enabled_provider = self.configure_google_provider(enabled=True)
@unittest.skipUnless(
@@ -41,28 +39,28 @@ class GetAuthenticatedUserTestCase(TestCase):
def test_raises_does_not_exist_if_user_missing(self):
with self.assertRaises(models.User.DoesNotExist):
pipeline.get_authenticated_user('new_' + self.user.username, 'backend')
pipeline.get_authenticated_user(self.enabled_provider, 'new_' + self.user.username, 'user@example.com')
def test_raises_does_not_exist_if_user_found_but_no_association(self):
backend_name = 'backend'
self.assertIsNotNone(self.get_by_username(self.user.username))
self.assertIsNone(provider.Registry.get_by_backend_name(backend_name))
self.assertFalse(any(provider.Registry.get_enabled_by_backend_name(backend_name)))
with self.assertRaises(models.User.DoesNotExist):
pipeline.get_authenticated_user(self.user.username, 'backend')
pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'user@example.com')
def test_raises_does_not_exist_if_user_and_association_found_but_no_match(self):
self.assertIsNotNone(self.get_by_username(self.user.username))
social_models.DjangoStorage.user.create_social_auth(
self.user, 'uid', 'other_' + self.enabled_provider.BACKEND_CLASS.name)
self.user, 'uid', 'other_' + self.enabled_provider.backend_name)
with self.assertRaises(models.User.DoesNotExist):
pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name)
pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid')
def test_returns_user_with_is_authenticated_and_backend_set_if_match(self):
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.BACKEND_CLASS.name)
user = pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name)
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.backend_name)
user = pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid')
self.assertEqual(self.user, user)
self.assertEqual(self.enabled_provider.get_authentication_backend(), user.backend)
@@ -78,55 +76,70 @@ class GetProviderUserStatesTestCase(testutil.TestCase, test.TestCase):
self.user = social_models.DjangoStorage.user.create_user(username='username', password='password')
def test_returns_empty_list_if_no_enabled_providers(self):
provider.Registry.configure_once([])
self.assertFalse(provider.Registry.enabled())
self.assertEquals([], pipeline.get_provider_user_states(self.user))
def test_state_not_returned_for_disabled_provider(self):
disabled_provider = provider.GoogleOauth2
enabled_provider = provider.LinkedInOauth2
provider.Registry.configure_once([enabled_provider.NAME])
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.BACKEND_CLASS.name)
disabled_provider = self.configure_google_provider(enabled=False)
enabled_provider = self.configure_facebook_provider(enabled=True)
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.backend_name)
states = pipeline.get_provider_user_states(self.user)
self.assertEqual(1, len(states))
self.assertNotIn(disabled_provider, (state.provider for state in states))
self.assertNotIn(disabled_provider.provider_id, (state.provider.provider_id for state in states))
self.assertIn(enabled_provider.provider_id, (state.provider.provider_id for state in states))
def test_states_for_enabled_providers_user_has_accounts_associated_with(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME])
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', provider.GoogleOauth2.BACKEND_CLASS.name)
social_models.DjangoStorage.user.create_social_auth(
self.user, 'uid', provider.LinkedInOauth2.BACKEND_CLASS.name)
# Enable two providers - Google and LinkedIn:
google_provider = self.configure_google_provider(enabled=True)
linkedin_provider = self.configure_linkedin_provider(enabled=True)
user_social_auth_google = social_models.DjangoStorage.user.create_social_auth(
self.user, 'uid', google_provider.backend_name)
user_social_auth_linkedin = social_models.DjangoStorage.user.create_social_auth(
self.user, 'uid', linkedin_provider.backend_name)
states = pipeline.get_provider_user_states(self.user)
self.assertEqual(2, len(states))
google_state = [state for state in states if state.provider == provider.GoogleOauth2][0]
linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0]
google_state = [state for state in states if state.provider.provider_id == google_provider.provider_id][0]
linkedin_state = [state for state in states if state.provider.provider_id == linkedin_provider.provider_id][0]
self.assertTrue(google_state.has_account)
self.assertEqual(provider.GoogleOauth2, google_state.provider)
self.assertEqual(google_provider.provider_id, google_state.provider.provider_id)
# Also check the row ID. Note this 'id' changes whenever the configuration does:
self.assertEqual(google_provider.id, google_state.provider.id) # pylint: disable=no-member
self.assertEqual(self.user, google_state.user)
self.assertEqual(user_social_auth_google.id, google_state.association_id)
self.assertTrue(linkedin_state.has_account)
self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider)
self.assertEqual(linkedin_provider.provider_id, linkedin_state.provider.provider_id)
self.assertEqual(linkedin_provider.id, linkedin_state.provider.id) # pylint: disable=no-member
self.assertEqual(self.user, linkedin_state.user)
self.assertEqual(user_social_auth_linkedin.id, linkedin_state.association_id)
def test_states_for_enabled_providers_user_has_no_account_associated_with(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME])
# Enable two providers - Google and LinkedIn:
google_provider = self.configure_google_provider(enabled=True)
linkedin_provider = self.configure_linkedin_provider(enabled=True)
self.assertEqual(len(provider.Registry.enabled()), 2)
states = pipeline.get_provider_user_states(self.user)
self.assertEqual([], [x for x in social_models.DjangoStorage.user.objects.all()])
self.assertEqual(2, len(states))
google_state = [state for state in states if state.provider == provider.GoogleOauth2][0]
linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0]
google_state = [state for state in states if state.provider.provider_id == google_provider.provider_id][0]
linkedin_state = [state for state in states if state.provider.provider_id == linkedin_provider.provider_id][0]
self.assertFalse(google_state.has_account)
self.assertEqual(provider.GoogleOauth2, google_state.provider)
self.assertEqual(google_provider.provider_id, google_state.provider.provider_id)
# Also check the row ID. Note this 'id' changes whenever the configuration does:
self.assertEqual(google_provider.id, google_state.provider.id) # pylint: disable=no-member
self.assertEqual(self.user, google_state.user)
self.assertFalse(linkedin_state.has_account)
self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider)
self.assertEqual(linkedin_provider.provider_id, linkedin_state.provider.provider_id)
self.assertEqual(linkedin_provider.id, linkedin_state.provider.id) # pylint: disable=no-member
self.assertEqual(self.user, linkedin_state.user)
@@ -136,7 +149,7 @@ class UrlFormationTestCase(TestCase):
"""Tests formation of URLs for pipeline hook points."""
def test_complete_url_raises_value_error_if_provider_not_enabled(self):
provider_name = 'not_enabled'
provider_name = 'oa2-not-enabled'
self.assertIsNone(provider.Registry.get(provider_name))
@@ -144,36 +157,54 @@ class UrlFormationTestCase(TestCase):
pipeline.get_complete_url(provider_name)
def test_complete_url_returns_expected_format(self):
complete_url = pipeline.get_complete_url(self.enabled_provider.BACKEND_CLASS.name)
complete_url = pipeline.get_complete_url(self.enabled_provider.backend_name)
self.assertTrue(complete_url.startswith('/auth/complete'))
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, complete_url)
self.assertIn(self.enabled_provider.backend_name, complete_url)
def test_disconnect_url_raises_value_error_if_provider_not_enabled(self):
provider_name = 'not_enabled'
provider_name = 'oa2-not-enabled'
self.assertIsNone(provider.Registry.get(provider_name))
with self.assertRaises(ValueError):
pipeline.get_disconnect_url(provider_name)
pipeline.get_disconnect_url(provider_name, 1000)
def test_disconnect_url_returns_expected_format(self):
disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.NAME)
self.assertTrue(disconnect_url.startswith('/auth/disconnect'))
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, disconnect_url)
disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.provider_id, 1000)
disconnect_url = disconnect_url.rstrip('?')
self.assertEqual(
disconnect_url,
'/auth/disconnect/{backend}/{association_id}/'.format(
backend=self.enabled_provider.backend_name, association_id=1000)
)
def test_login_url_raises_value_error_if_provider_not_enabled(self):
provider_name = 'not_enabled'
provider_id = 'oa2-not-enabled'
self.assertIsNone(provider.Registry.get(provider_name))
self.assertIsNone(provider.Registry.get(provider_id))
with self.assertRaises(ValueError):
pipeline.get_login_url(provider_name, pipeline.AUTH_ENTRY_LOGIN)
pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN)
def test_login_url_returns_expected_format(self):
login_url = pipeline.get_login_url(self.enabled_provider.NAME, pipeline.AUTH_ENTRY_LOGIN)
login_url = pipeline.get_login_url(self.enabled_provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)
self.assertTrue(login_url.startswith('/auth/login'))
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, login_url)
self.assertIn(self.enabled_provider.backend_name, login_url)
self.assertTrue(login_url.endswith(pipeline.AUTH_ENTRY_LOGIN))
def test_for_value_error_if_provider_id_invalid(self):
provider_id = 'invalid' # Format is normally "{prefix}-{identifier}"
with self.assertRaises(ValueError):
provider.Registry.get(provider_id)
with self.assertRaises(ValueError):
pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN)
with self.assertRaises(ValueError):
pipeline.get_disconnect_url(provider_id, 1000)
with self.assertRaises(ValueError):
pipeline.get_complete_url(provider_id)

View File

@@ -1,82 +1,84 @@
"""Unit tests for provider.py."""
from mock import Mock, patch
from third_party_auth import provider
from third_party_auth.tests import testutil
import unittest
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
class RegistryTest(testutil.TestCase):
"""Tests registry discovery and operation."""
# Allow access to protected methods (or module-protected methods) under
# test. pylint: disable-msg=protected-access
def test_calling_configure_once_twice_raises_value_error(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
with self.assertRaisesRegexp(ValueError, '^.*already configured$'):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
def test_configure_once_adds_gettable_providers(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME))
facebook_provider = self.configure_facebook_provider(enabled=True)
# pylint: disable=no-member
self.assertEqual(facebook_provider.id, provider.Registry.get(facebook_provider.provider_id).id)
def test_configuring_provider_with_no_implementation_raises_value_error(self):
with self.assertRaisesRegexp(ValueError, '^.*no_implementation$'):
provider.Registry.configure_once(['no_implementation'])
def test_no_providers_by_default(self):
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 0, "By default, no providers are enabled.")
def test_configuring_single_provider_twice_raises_value_error(self):
provider.Registry._enable(provider.GoogleOauth2)
def test_runtime_configuration(self):
self.configure_google_provider(enabled=True)
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 1)
self.assertEqual(enabled_providers[0].name, "Google")
self.assertEqual(enabled_providers[0].secret, "opensesame")
with self.assertRaisesRegexp(ValueError, '^.*already enabled'):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.configure_google_provider(enabled=False)
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 0)
def test_custom_provider_can_be_enabled(self):
name = 'CustomProvider'
self.configure_google_provider(enabled=True, secret="alohomora")
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 1)
self.assertEqual(enabled_providers[0].secret, "alohomora")
with self.assertRaisesRegexp(ValueError, '^No implementation.*$'):
provider.Registry.configure_once([name])
class CustomProvider(provider.BaseProvider):
"""Custom class to ensure BaseProvider children outside provider can be enabled."""
NAME = name
provider.Registry._reset()
provider.Registry.configure_once([CustomProvider.NAME])
self.assertEqual([CustomProvider], provider.Registry.enabled())
def test_enabled_raises_runtime_error_if_not_configured(self):
with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'):
provider.Registry.enabled()
def test_cannot_load_arbitrary_backends(self):
""" Test that only backend_names listed in settings.AUTHENTICATION_BACKENDS can be used """
self.configure_oauth_provider(enabled=True, name="Disallowed", backend_name="disallowed")
self.enable_saml()
self.configure_saml_provider(enabled=True, name="Disallowed", idp_slug="test", backend_name="disallowed")
self.assertEqual(len(provider.Registry.enabled()), 0)
def test_enabled_returns_list_of_enabled_providers_sorted_by_name(self):
all_providers = provider.Registry._get_all()
provider.Registry.configure_once(all_providers.keys())
self.assertEqual(
sorted(all_providers.values(), key=lambda provider: provider.NAME), provider.Registry.enabled())
provider_names = ["Stack Overflow", "Google", "LinkedIn", "GitHub"]
backend_names = []
for name in provider_names:
backend_name = name.lower().replace(' ', '')
backend_names.append(backend_name)
self.configure_oauth_provider(enabled=True, name=name, backend_name=backend_name)
def test_get_raises_runtime_error_if_not_configured(self):
with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'):
provider.Registry.get('anything')
with patch('third_party_auth.provider._PSA_OAUTH2_BACKENDS', backend_names):
self.assertEqual(sorted(provider_names), [prov.name for prov in provider.Registry.enabled()])
def test_get_returns_enabled_provider(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME))
google_provider = self.configure_google_provider(enabled=True)
# pylint: disable=no-member
self.assertEqual(google_provider.id, provider.Registry.get(google_provider.provider_id).id)
def test_get_returns_none_if_provider_not_enabled(self):
provider.Registry.configure_once([])
self.assertIsNone(provider.Registry.get(provider.LinkedInOauth2.NAME))
linkedin_provider_id = "oa2-linkedin-oauth2"
# At this point there should be no configuration entries at all so no providers should be enabled
self.assertEqual(provider.Registry.enabled(), [])
self.assertIsNone(provider.Registry.get(linkedin_provider_id))
# Now explicitly disabled this provider:
self.configure_linkedin_provider(enabled=False)
self.assertIsNone(provider.Registry.get(linkedin_provider_id))
self.configure_linkedin_provider(enabled=True)
self.assertEqual(provider.Registry.get(linkedin_provider_id).provider_id, linkedin_provider_id)
def test_get_by_backend_name_raises_runtime_error_if_not_configured(self):
with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'):
provider.Registry.get_by_backend_name('')
def test_get_from_pipeline_returns_none_if_provider_not_enabled(self):
self.assertEqual(provider.Registry.enabled(), [], "By default, no providers are enabled.")
self.assertIsNone(provider.Registry.get_from_pipeline(Mock()))
def test_get_by_backend_name_returns_enabled_provider(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.assertIs(
provider.GoogleOauth2,
provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name))
def test_get_enabled_by_backend_name_returns_enabled_provider(self):
google_provider = self.configure_google_provider(enabled=True)
found = list(provider.Registry.get_enabled_by_backend_name(google_provider.backend_name))
self.assertEqual(found, [google_provider])
def test_get_by_backend_name_returns_none_if_provider_not_enabled(self):
provider.Registry.configure_once([])
self.assertIsNone(provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name))
def test_get_enabled_by_backend_name_returns_none_if_provider_not_enabled(self):
google_provider = self.configure_google_provider(enabled=False)
found = list(provider.Registry.get_enabled_by_backend_name(google_provider.backend_name))
self.assertEqual(found, [])

View File

@@ -2,6 +2,7 @@
from third_party_auth import provider, settings
from third_party_auth.tests import testutil
import unittest
_ORIGINAL_AUTHENTICATION_BACKENDS = ('first_authentication_backend',)
@@ -30,56 +31,26 @@ class SettingsUnitTest(testutil.TestCase):
self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP)
def test_apply_settings_adds_exception_middleware(self):
settings.apply_settings({}, self.settings)
settings.apply_settings(self.settings)
for middleware_name in settings._MIDDLEWARE_CLASSES:
self.assertIn(middleware_name, self.settings.MIDDLEWARE_CLASSES)
def test_apply_settings_adds_fields_stored_in_session(self):
settings.apply_settings({}, self.settings)
settings.apply_settings(self.settings)
self.assertEqual(settings._FIELDS_STORED_IN_SESSION, self.settings.FIELDS_STORED_IN_SESSION)
def test_apply_settings_adds_third_party_auth_to_installed_apps(self):
settings.apply_settings({}, self.settings)
settings.apply_settings(self.settings)
self.assertIn('third_party_auth', self.settings.INSTALLED_APPS)
def test_apply_settings_enables_no_providers_and_completes_when_app_info_empty(self):
settings.apply_settings({}, self.settings)
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
def test_apply_settings_enables_no_providers_by_default(self):
# Providers are only enabled via ConfigurationModels in the database
settings.apply_settings(self.settings)
self.assertEqual([], provider.Registry.enabled())
def test_apply_settings_initializes_stubs_and_merges_settings_from_auth_info(self):
for key in provider.GoogleOauth2.SETTINGS:
self.assertFalse(hasattr(self.settings, key))
auth_info = {
provider.GoogleOauth2.NAME: {
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key',
},
}
settings.apply_settings(auth_info, self.settings)
self.assertEqual('google_oauth2_key', self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY)
self.assertIsNone(self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET)
def test_apply_settings_prepends_auth_backends(self):
self.assertEqual(_ORIGINAL_AUTHENTICATION_BACKENDS, self.settings.AUTHENTICATION_BACKENDS)
settings.apply_settings({provider.GoogleOauth2.NAME: {}, provider.LinkedInOauth2.NAME: {}}, self.settings)
self.assertEqual((
provider.GoogleOauth2.get_authentication_backend(), provider.LinkedInOauth2.get_authentication_backend()) +
_ORIGINAL_AUTHENTICATION_BACKENDS,
self.settings.AUTHENTICATION_BACKENDS)
def test_apply_settings_raises_value_error_if_provider_contains_uninitialized_setting(self):
bad_setting_name = 'bad_setting'
self.assertNotIn('bad_setting_name', provider.GoogleOauth2.SETTINGS)
auth_info = {
provider.GoogleOauth2.NAME: {
bad_setting_name: None,
},
}
with self.assertRaisesRegexp(ValueError, '^.*not initialized$'):
settings.apply_settings(auth_info, self.settings)
def test_apply_settings_turns_off_raising_social_exceptions(self):
# Guard against submitting a conf change that's convenient in dev but
# bad in prod.
settings.apply_settings({}, self.settings)
settings.apply_settings(self.settings)
self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS)

View File

@@ -1,27 +0,0 @@
"""Integration tests for settings.py."""
from django.conf import settings
from third_party_auth import provider
from third_party_auth import settings as auth_settings
from third_party_auth.tests import testutil
class SettingsIntegrationTest(testutil.TestCase):
"""Integration tests of auth settings pipeline.
Note that ENABLE_THIRD_PARTY_AUTH is True in lms/envs/test.py and False in
cms/envs/test.py. This implicitly gives us coverage of the full settings
mechanism with both values, so we do not have explicit test methods as they
are superfluous.
"""
def test_can_enable_google_oauth2(self):
auth_settings.apply_settings({'Google': {'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_key'}}, settings)
self.assertEqual([provider.GoogleOauth2], provider.Registry.enabled())
self.assertEqual('google_key', settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY)
def test_can_enable_linkedin_oauth2(self):
auth_settings.apply_settings({'LinkedIn': {'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_key'}}, settings)
self.assertEqual([provider.LinkedInOauth2], provider.Registry.enabled())
self.assertEqual('linkedin_key', settings.SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY)

View File

@@ -0,0 +1,64 @@
"""
Test the views served by third_party_auth.
"""
# pylint: disable=no-member
import ddt
from lxml import etree
import unittest
from .testutil import AUTH_FEATURE_ENABLED, SAMLTestCase
# Define some XML namespaces:
from third_party_auth.tasks import SAML_XML_NS
XMLDSIG_XML_NS = 'http://www.w3.org/2000/09/xmldsig#'
@unittest.skipUnless(AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
@ddt.ddt
class SAMLMetadataTest(SAMLTestCase):
"""
Test the SAML metadata view
"""
METADATA_URL = '/auth/saml/metadata.xml'
def test_saml_disabled(self):
""" When SAML is not enabled, the metadata view should return 404 """
self.enable_saml(enabled=False)
response = self.client.get(self.METADATA_URL)
self.assertEqual(response.status_code, 404)
@ddt.data('saml_key', 'saml_key_alt') # Test two slightly different key pair export formats
def test_metadata(self, key_name):
self.enable_saml(
private_key=self._get_private_key(key_name),
public_key=self._get_public_key(key_name),
entity_id="https://saml.example.none",
)
doc = self._fetch_metadata()
# Check the ACS URL:
acs_node = doc.find(".//{}".format(etree.QName(SAML_XML_NS, 'AssertionConsumerService')))
self.assertIsNotNone(acs_node)
self.assertEqual(acs_node.attrib['Location'], 'http://example.none/auth/complete/tpa-saml/')
def test_signed_metadata(self):
self.enable_saml(
private_key=self._get_private_key(),
public_key=self._get_public_key(),
entity_id="https://saml.example.none",
other_config_str='{"SECURITY_CONFIG": {"signMetadata": true} }',
)
doc = self._fetch_metadata()
sig_node = doc.find(".//{}".format(etree.QName(XMLDSIG_XML_NS, 'SignatureValue')))
self.assertIsNotNone(sig_node)
def _fetch_metadata(self):
""" Fetch and parse the metadata XML at self.METADATA_URL """
response = self.client.get(self.METADATA_URL)
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/xml')
# The result should be valid XML:
try:
metadata_doc = etree.fromstring(response.content)
except etree.LxmlError:
self.fail('SAML metadata must be valid XML')
self.assertEqual(metadata_doc.tag, etree.QName(SAML_XML_NS, 'EntityDescriptor'))
return metadata_doc

View File

@@ -5,13 +5,16 @@ Used by Django and non-Django tests; must not have Django deps.
"""
from contextlib import contextmanager
import unittest
from django.conf import settings
import django.test
import mock
import os.path
from third_party_auth import provider
from third_party_auth.models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, cache as config_cache
AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH'
AUTH_FEATURE_ENABLED = AUTH_FEATURES_KEY in settings.FEATURES
class FakeDjangoSettings(object):
@@ -23,22 +26,93 @@ class FakeDjangoSettings(object):
setattr(self, key, value)
class TestCase(unittest.TestCase):
"""Base class for auth test cases."""
# Allow access to protected methods (or module-protected methods) under
# test.
# pylint: disable-msg=protected-access
def setUp(self):
super(TestCase, self).setUp()
self._original_providers = provider.Registry._get_all()
provider.Registry._reset()
class ThirdPartyAuthTestMixin(object):
""" Helper methods useful for testing third party auth functionality """
def tearDown(self):
provider.Registry._reset()
provider.Registry.configure_once(self._original_providers)
super(TestCase, self).tearDown()
config_cache.clear()
super(ThirdPartyAuthTestMixin, self).tearDown()
def enable_saml(self, **kwargs):
""" Enable SAML support (via SAMLConfiguration, not for any particular provider) """
kwargs.setdefault('enabled', True)
SAMLConfiguration(**kwargs).save()
@staticmethod
def configure_oauth_provider(**kwargs):
""" Update the settings for an OAuth2-based third party auth provider """
obj = OAuth2ProviderConfig(**kwargs)
obj.save()
return obj
def configure_saml_provider(self, **kwargs):
""" Update the settings for a SAML-based third party auth provider """
self.assertTrue(SAMLConfiguration.is_enabled(), "SAML Provider Configuration only works if SAML is enabled.")
obj = SAMLProviderConfig(**kwargs)
obj.save()
return obj
@classmethod
def configure_google_provider(cls, **kwargs):
""" Update the settings for the Google third party auth provider/backend """
kwargs.setdefault("name", "Google")
kwargs.setdefault("backend_name", "google-oauth2")
kwargs.setdefault("icon_class", "fa-google-plus")
kwargs.setdefault("key", "test-fake-key.apps.googleusercontent.com")
kwargs.setdefault("secret", "opensesame")
return cls.configure_oauth_provider(**kwargs)
@classmethod
def configure_facebook_provider(cls, **kwargs):
""" Update the settings for the Facebook third party auth provider/backend """
kwargs.setdefault("name", "Facebook")
kwargs.setdefault("backend_name", "facebook")
kwargs.setdefault("icon_class", "fa-facebook")
kwargs.setdefault("key", "FB_TEST_APP")
kwargs.setdefault("secret", "opensesame")
return cls.configure_oauth_provider(**kwargs)
@classmethod
def configure_linkedin_provider(cls, **kwargs):
""" Update the settings for the LinkedIn third party auth provider/backend """
kwargs.setdefault("name", "LinkedIn")
kwargs.setdefault("backend_name", "linkedin-oauth2")
kwargs.setdefault("icon_class", "fa-linkedin")
kwargs.setdefault("key", "test")
kwargs.setdefault("secret", "test")
return cls.configure_oauth_provider(**kwargs)
class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase):
"""Base class for auth test cases."""
pass
class SAMLTestCase(TestCase):
"""
Base class for SAML-related third_party_auth tests
"""
def setUp(self):
super(SAMLTestCase, self).setUp()
self.client.defaults['SERVER_NAME'] = 'example.none' # The SAML lib we use doesn't like testserver' as a domain
self.url_prefix = 'http://example.none'
@classmethod
def _get_public_key(cls, key_name='saml_key'):
""" Get a public key for use in the test. """
return cls._read_data_file('{}.pub'.format(key_name))
@classmethod
def _get_private_key(cls, key_name='saml_key'):
""" Get a private key for use in the test. """
return cls._read_data_file('{}.key'.format(key_name))
@staticmethod
def _read_data_file(filename):
""" Read the contents of a file in the data folder """
with open(os.path.join(os.path.dirname(__file__), 'data', filename)) as f:
return f.read()
@contextmanager

View File

@@ -9,9 +9,11 @@ from social.apps.django_app.default.models import UserSocialAuth
from student.tests.factories import UserFactory
from .testutil import ThirdPartyAuthTestMixin
@httpretty.activate
class ThirdPartyOAuthTestMixin(object):
class ThirdPartyOAuthTestMixin(ThirdPartyAuthTestMixin):
"""
Mixin with tests for third party oauth views. A TestCase that includes
this must define the following:
@@ -32,6 +34,10 @@ class ThirdPartyOAuthTestMixin(object):
if create_user:
self.user = UserFactory()
UserSocialAuth.objects.create(user=self.user, provider=self.BACKEND, uid=self.social_uid)
if self.BACKEND == 'google-oauth2':
self.configure_google_provider(enabled=True)
elif self.BACKEND == 'facebook':
self.configure_facebook_provider(enabled=True)
def _setup_provider_response(self, success=False, email=''):
"""

View File

@@ -2,10 +2,11 @@
from django.conf.urls import include, patterns, url
from .views import inactive_user_view
from .views import inactive_user_view, saml_metadata_view
urlpatterns = patterns(
'',
url(r'^auth/inactive', inactive_user_view),
url(r'^auth/saml/metadata.xml', saml_metadata_view),
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
)

View File

@@ -1,7 +1,12 @@
"""
Extra views required for SSO
"""
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseServerError, Http404
from django.shortcuts import redirect
from social.apps.django_app.utils import load_strategy, load_backend
from .models import SAMLConfiguration
def inactive_user_view(request):
@@ -13,3 +18,21 @@ def inactive_user_view(request):
# in a course. Otherwise, just redirect them to the dashboard, which displays a message
# about activating their account.
return redirect(request.GET.get('next', 'dashboard'))
def saml_metadata_view(request):
"""
Get the Service Provider metadata for this edx-platform instance.
You must send this XML to any Shibboleth Identity Provider that you wish to use.
"""
if not SAMLConfiguration.is_enabled():
raise Http404
complete_url = reverse('social:complete', args=("tpa-saml", ))
if settings.APPEND_SLASH and not complete_url.endswith('/'):
complete_url = complete_url + '/' # Required for consistency
saml_backend = load_backend(load_strategy(request), "tpa-saml", redirect_uri=complete_url)
metadata, errors = saml_backend.generate_metadata_xml()
if not errors:
return HttpResponse(content=metadata, content_type='text/xml')
return HttpResponseServerError(content=', '.join(errors))

View File

@@ -2,7 +2,7 @@
Convenience methods for working with datetime objects
"""
from datetime import timedelta
from datetime import datetime, timedelta
import re
from pytz import timezone, UTC, UnknownTimeZoneError
@@ -73,6 +73,27 @@ def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)):
return abs(dt1 - dt2) < allowed_delta
def to_timestamp(datetime_value):
"""
Convert a datetime into a timestamp, represented as the number
of seconds since January 1, 1970 UTC.
"""
return int((datetime_value - datetime(1970, 1, 1, tzinfo=UTC)).total_seconds())
def from_timestamp(timestamp):
"""
Convert a timestamp (number of seconds since Jan 1, 1970 UTC)
into a timezone-aware datetime.
If the timestamp cannot be converted, returns None instead.
"""
try:
return datetime.utcfromtimestamp(int(timestamp)).replace(tzinfo=UTC)
except (ValueError, TypeError):
return None
DEFAULT_SHORT_DATE_FORMAT = "%b %d, %Y"
DEFAULT_LONG_DATE_FORMAT = "%A, %B %d, %Y"
DEFAULT_TIME_FORMAT = "%I:%M:%S %p"

View File

@@ -69,7 +69,7 @@ registry = TagRegistry() # pylint: disable=invalid-name
class Status(object):
"""
Problem status
attributes: classname, display_name
attributes: classname, display_name, display_tooltip
"""
css_classes = {
# status: css class
@@ -77,7 +77,7 @@ class Status(object):
'incomplete': 'incorrect',
'queued': 'processing',
}
__slots__ = ('classname', '_status', 'display_name')
__slots__ = ('classname', '_status', 'display_name', 'display_tooltip')
def __init__(self, status, gettext_func=unicode):
self.classname = self.css_classes.get(status, status)
@@ -90,7 +90,16 @@ class Status(object):
'unsubmitted': _('unanswered'),
'queued': _('processing'),
}
tooltips = {
# Translators: these are tooltips that indicate the state of an assessment question
'correct': _('This is correct.'),
'incorrect': _('This is incorrect.'),
'unanswered': _('This is unanswered.'),
'unsubmitted': _('This is unanswered.'),
'queued': _('This is being processed.'),
}
self.display_name = names.get(status, unicode(status))
self.display_tooltip = tooltips.get(status, u'')
self._status = status or ''
def __str__(self):

View File

@@ -1,24 +1,5 @@
<form class="choicegroup capa_inputtype" id="inputtype_${id}">
<div class="indicator_container">
% if input_type == 'checkbox' or not value:
<span class="status ${status.classname if show_correctness != 'never' else 'unanswered'}"
id="status_${id}"
aria-describedby="inputtype_${id}">
<span class="sr">
%for choice_id, choice_description in choices:
% if choice_id in value:
${choice_description},
%endif
%endfor
-
${status.display_name}
</span>
</span>
% endif
</div>
<fieldset role="${input_type}group" aria-label="${label}">
% for choice_id, choice_description in choices:
<label for="input_${id}_${choice_id}"
## If the student has selected this choice...
@@ -58,7 +39,21 @@
% endfor
<span id="answer_${id}"></span>
</fieldset>
<div class="indicator-container">
% if input_type == 'checkbox' or not value:
<span class="status ${status.classname if show_correctness != 'never' else 'unanswered'}" id="status_${id}" aria-describedby="inputtype_${id}" data-tooltip="${status.display_tooltip}">
<span class="sr">
%for choice_id, choice_description in choices:
% if choice_id in value:
${choice_description},
%endif
%endfor
-
${status.display_name}
</span>
</span>
% endif
</div>
% if show_correctness == "never" and (value or status not in ['unsubmitted']):
<div class="capa_alert">${submitted_message}</div>
%endif

View File

@@ -9,12 +9,7 @@
<section id="choicetextinput_${id}" class="choicetextinput">
<form class="choicetextgroup capa_inputtype" id="inputtype_${id}">
<div class="script_placeholder" data-src="${STATIC_URL}js/capa/choicetextinput.js"/>
<div class="indicator_container">
% if input_type == 'checkbox' or not element_checked:
<span class="status ${status.classname}" id="status_${id}"></span>
% endif
</div>
<fieldset aria-label="${label}">
% for choice_id, choice_description in choices:
<%choice_id= choice_id %>
@@ -62,6 +57,13 @@
<span id="answer_${id}"></span>
</fieldset>
<input class= "choicetextvalue" type="hidden" name="input_${id}{}" id="input_${id}" value="${value|h}" />
<div class="indicator-container">
% if input_type == 'checkbox' or not element_checked:
<span class="status ${status.classname}" id="status_${id}"></span>
% endif
</div>
% if show_correctness == "never" and (value or status not in ['unsubmitted']):
<div class="capa_alert">${_(submitted_message)}</div>
%endif

View File

@@ -10,9 +10,11 @@
% endif
/>
<p class="status" id="${id}_status">
${status.display_name}
</p>
<span class="status" id="${id}_status" data-tooltip="${status.display_tooltip}">
<span class="sr">
${status.display_name}
</span>
</span>
<div id="input_${id}_preview" class="equation">
\[\]

View File

@@ -49,9 +49,8 @@
</div>
%endif
<script>
<script type="text/javascript">
$(function(){
var IntervalManager, PendingMatlabResult;
var gentle_alert = function (parent_elt, msg) {
if($(parent_elt).find('.capa_alert').length) {
$(parent_elt).find('.capa_alert').remove();
@@ -63,7 +62,6 @@
// hook up the plot button
var plot = function(event) {
var matlab_result_task;
var problem_elt = $(event.target).closest('.problems-wrapper');
url = $(event.target).closest('.problems-wrapper').data('url');
input_id = "${id}";
@@ -82,27 +80,36 @@
answer = input.serialize();
// a chain of callbacks, each querying the server on success of the previous one
var get_callback = function(response) {
var new_result_elem = $(response.html).find(".ungraded-matlab-result").html();
var external_grader_msg = $(response.html).find(".external-grader-message").html();
result_elem = $(problem_elt).find(".ungraded-matlab-result");
result_elem.addClass("is-fading-in");
result_elem.html(new_result_elem);
external_grader_msg_elem = $(problem_elt).find(".external-grader-message");
external_grader_msg_elem.addClass("is-fading-in");
external_grader_msg_elem.html(external_grader_msg);
if (!external_grader_msg.trim()) {
matlab_result_task.task_poller.stop();
} else {
result_elem.html('');
}
var poll = function(prev_timeout) {
$.postWithPrefix(url + "/problem_get", function(response) {
var new_result_elem = $(response.html).find(".ungraded-matlab-result").html();
var external_grader_msg = $(response.html).find(".external-grader-message").html();
var result_elem = $(problem_elt).find(".ungraded-matlab-result");
result_elem.addClass("is-fading-in");
result_elem.html(new_result_elem);
var external_grader_msg_elem = $(problem_elt).find(".external-grader-message");
external_grader_msg_elem.addClass("is-fading-in");
external_grader_msg_elem.html(external_grader_msg);
// If we have a message about waiting for the external grader.
if (external_grader_msg.trim()) {
result_elem.html('');
// Setup the polling for the next round
var next_timeout = prev_timeout * 2;
// The XML parsing that capa uses doesn't handle the greater-than symbol properly here, so we are forced to work around it.
// The backend MatlabInput code will also terminate after 35 seconds, so this is mostly a protective measure.
if (next_timeout === 64000) {
gentle_alert(problem_elt, gettext("Your code is still being run. Refresh the page to see updates."));
}
window.setTimeout(function(){ poll(next_timeout); }, next_timeout);
}
});
};
var plot_callback = function(response) {
if(response.success) {
matlab_result_task = new PendingMatlabResult(get_callback);
matlab_result_task.task_poller.start();
// If successful, start polling.
// If we change the initial polling value, we will also need to change the check within poll (next_time === 64000) to match it.
poll(1000);
} else {
// Used response.message because input_ajax is returning "message"
gentle_alert(problem_elt, response.message);
@@ -126,55 +133,6 @@
};
$('#plot_${id}').click(plot);
// Copied from lms/static/coffee/src/instructor_dashboard/util.js
IntervalManager = (function() {
function IntervalManager(ms, fn) {
this.ms = ms;
this.fn = fn;
this.intervalID = null;
}
IntervalManager.prototype.start = function() {
this.fn();
if (this.intervalID === null) {
return this.intervalID = setInterval(this.fn, this.ms);
}
};
IntervalManager.prototype.stop = function() {
clearInterval(this.intervalID);
return this.intervalID = null;
};
return IntervalManager;
})();
PendingMatlabResult = (function() {
/* Pending Matlab Result Section
*/
function PendingMatlabResult(get_callback) {
var MATLAB_RESULT_POLL_INTERVAL,
_this = this;
this.reload_matlab_result = function(get_callback) {
return PendingMatlabResult.prototype.reload_matlab_result.apply(_this, arguments);
};
MATLAB_RESULT_POLL_INTERVAL = 1000;
this.reload_matlab_result(get_callback);
this.task_poller = new IntervalManager(MATLAB_RESULT_POLL_INTERVAL, function() {
return _this.reload_matlab_result(get_callback);
});
}
PendingMatlabResult.prototype.reload_matlab_result = function(get_callback) {
return $.postWithPrefix(url + "/problem_get", get_callback)
};
return PendingMatlabResult;
})();
});
</script>
</section>

View File

@@ -13,12 +13,13 @@
</select>
<span id="answer_${id}"></span>
<span class="status ${status.classname}"
id="status_${id}"
aria-describedby="input_${id}">
<span class="sr">${value|h} - ${status.display_name}</span>
</span>
<div class="indicator-container">
<span class="status ${status.classname}"
id="status_${id}"
aria-describedby="input_${id}" data-tooltip="${status.display_tooltip}">
<span class="sr">${value|h} - ${status.display_name}</span>
</span>
</div>
% if msg:
<span class="message">${msg|n}</span>
% endif

View File

@@ -27,18 +27,20 @@
/>
${trailing_text | h}
<p class="status"
<span class="status"
%if status != 'unsubmitted':
%endif
aria-describedby="input_${id}">
%if value:
${value|h}
% else:
${label}
%endif
-
${status.display_name}
</p>
aria-describedby="input_${id}" data-tooltip="${status.display_tooltip}">
<span class="sr">
%if value:
${value|h}
% else:
${label}
%endif
-
${status.display_name}
</span>
</span>
<p id="answer_${id}" class="answer"></p>

View File

@@ -144,7 +144,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
# Should mark the entire problem correct
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='status correct']"
xpath = "//div[@class='indicator-container']/span[@class='status correct']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
@@ -172,7 +172,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='status incorrect']"
xpath = "//div[@class='indicator-container']/span[@class='status incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
@@ -204,7 +204,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='status unanswered']"
xpath = "//div[@class='indicator-container']/span[@class='status unanswered']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
@@ -234,7 +234,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
xpath = "//div[@class='indicator-container']/span"
self.assert_no_xpath(xml, xpath, self.context)
def test_option_marked_incorrect(self):
@@ -255,7 +255,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
xpath = "//div[@class='indicator-container']/span"
self.assert_no_xpath(xml, xpath, self.context)
def test_never_show_correctness(self):
@@ -289,10 +289,10 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
xml = self.render_to_xml(self.context)
# Should NOT mark the entire problem correct/incorrect
xpath = "//div[@class='indicator_container']/span[@class='status correct']"
xpath = "//div[@class='indicator-container']/span[@class='status correct']"
self.assert_no_xpath(xml, xpath, self.context)
xpath = "//div[@class='indicator_container']/span[@class='status incorrect']"
xpath = "//div[@class='indicator-container']/span[@class='status incorrect']"
self.assert_no_xpath(xml, xpath, self.context)
# Should NOT mark individual options
@@ -388,9 +388,9 @@ class TextlineTemplateTest(TemplateTestCase):
xpath = "//div[@class='%s ']" % div_class
self.assert_has_xpath(xml, xpath, self.context)
# Expect that we get a <p> with class="status"
# Expect that we get a <span> with class="status"
# (used to by CSS to draw the green check / red x)
self.assert_has_text(xml, "//p[@class='status']",
self.assert_has_text(xml, "//span[@class='status']/span[@class='sr']",
status_mark, exact=False)
def test_label(self):
@@ -848,7 +848,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
# Should mark the entire problem correct
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='status correct']"
xpath = "//div[@class='indicator-container']/span[@class='status correct']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
@@ -875,7 +875,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='status incorrect']"
xpath = "//div[@class='indicator-container']/span[@class='status incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
@@ -907,7 +907,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='status unanswered']"
xpath = "//div[@class='indicator-container']/span[@class='status unanswered']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
@@ -937,7 +937,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
xpath = "//div[@class='indicator-container']/span"
self.assert_no_xpath(xml, xpath, self.context)
def test_option_marked_incorrect(self):
@@ -957,7 +957,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
xpath = "//div[@class='indicator-container']/span"
self.assert_no_xpath(xml, xpath, self.context)
def test_label(self):

View File

@@ -9,7 +9,7 @@ For processing xml always prefer this over using lxml.etree directly.
from lxml.etree import * # pylint: disable=wildcard-import, unused-wildcard-import
from lxml.etree import XMLParser as _XMLParser
from lxml.etree import _ElementTree # pylint: disable=unused-import
from lxml.etree import _Element, _ElementTree # pylint: disable=unused-import, no-name-in-module
# This should be imported after lxml.etree so that it overrides the following attributes.
from defusedxml.lxml import parse, fromstring, XML

View File

@@ -0,0 +1,211 @@
"""
Simple utility functions that operate on course metadata.
This is a place to put simple functions that operate on course metadata. It
allows us to share code between the CourseDescriptor and CourseOverview
classes, which both need these type of functions.
"""
from datetime import datetime
from base64 import b32encode
from django.utils.timezone import UTC
from .fields import Date
DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=UTC())
def clean_course_key(course_key, padding_char):
"""
Encode a course's key into a unique, deterministic base32-encoded ID for
the course.
Arguments:
course_key (CourseKey): A course key.
padding_char (str): Character used for padding at end of the encoded
string. The standard value for this is '='.
"""
return "course_{}".format(
b32encode(unicode(course_key)).replace('=', padding_char)
)
def url_name_for_course_location(location):
"""
Given a course's usage locator, returns the course's URL name.
Arguments:
location (BlockUsageLocator): The course's usage locator.
"""
return location.name
def display_name_with_default(course):
"""
Calculates the display name for a course.
Default to the display_name if it isn't None, else fall back to creating
a name based on the URL.
Unlike the rest of this module's functions, this function takes an entire
course descriptor/overview as a parameter. This is because a few test cases
(specifically, {Text|Image|Video}AnnotationModuleTestCase.test_student_view)
create scenarios where course.display_name is not None but course.location
is None, which causes calling course.url_name to fail. So, although we'd
like to just pass course.display_name and course.url_name as arguments to
this function, we can't do so without breaking those tests.
Arguments:
course (CourseDescriptor|CourseOverview): descriptor or overview of
said course.
"""
# TODO: Consider changing this to use something like xml.sax.saxutils.escape
return (
course.display_name if course.display_name is not None
else course.url_name.replace('_', ' ')
).replace('<', '&lt;').replace('>', '&gt;')
def number_for_course_location(location):
"""
Given a course's block usage locator, returns the course's number.
This is a "number" in the sense of the "course numbers" that you see at
lots of universities. For example, given a course
"Intro to Computer Science" with the course key "edX/CS-101/2014", the
course number would be "CS-101"
Arguments:
location (BlockUsageLocator): The usage locator of the course in
question.
"""
return location.course
def has_course_started(start_date):
"""
Given a course's start datetime, returns whether the current time's past it.
Arguments:
start_date (datetime): The start datetime of the course in question.
"""
# TODO: This will throw if start_date is None... consider changing this behavior?
return datetime.now(UTC()) > start_date
def has_course_ended(end_date):
"""
Given a course's end datetime, returns whether
(a) it is not None, and
(b) the current time is past it.
Arguments:
end_date (datetime): The end datetime of the course in question.
"""
return datetime.now(UTC()) > end_date if end_date is not None else False
def course_start_date_is_default(start, advertised_start):
"""
Returns whether a course's start date hasn't yet been set.
Arguments:
start (datetime): The start datetime of the course in question.
advertised_start (str): The advertised start date of the course
in question.
"""
return advertised_start is None and start == DEFAULT_START_DATE
def _datetime_to_string(date_time, format_string, strftime_localized):
"""
Formats the given datetime with the given function and format string.
Adds UTC to the resulting string if the format is DATE_TIME or TIME.
Arguments:
date_time (datetime): the datetime to be formatted
format_string (str): the date format type, as passed to strftime
strftime_localized ((datetime, str) -> str): a nm localized string
formatting function
"""
# TODO: Is manually appending UTC really the right thing to do here? What if date_time isn't UTC?
result = strftime_localized(date_time, format_string)
return (
result + u" UTC" if format_string in ['DATE_TIME', 'TIME']
else result
)
def course_start_datetime_text(start_date, advertised_start, format_string, ugettext, strftime_localized):
"""
Calculates text to be shown to user regarding a course's start
datetime in UTC.
Prefers .advertised_start, then falls back to .start.
Arguments:
start_date (datetime): the course's start datetime
advertised_start (str): the course's advertised start date
format_string (str): the date format type, as passed to strftime
ugettext ((str) -> str): a text localization function
strftime_localized ((datetime, str) -> str): a localized string
formatting function
"""
if advertised_start is not None:
# TODO: This will return an empty string if advertised_start == ""... consider changing this behavior?
try:
# from_json either returns a Date, returns None, or raises a ValueError
parsed_advertised_start = Date().from_json(advertised_start)
if parsed_advertised_start is not None:
# In the Django implementation of strftime_localized, if
# the year is <1900, _datetime_to_string will raise a ValueError.
return _datetime_to_string(parsed_advertised_start, format_string, strftime_localized)
except ValueError:
pass
return advertised_start.title()
elif start_date != DEFAULT_START_DATE:
return _datetime_to_string(start_date, format_string, strftime_localized)
else:
_ = ugettext
# Translators: TBD stands for 'To Be Determined' and is used when a course
# does not yet have an announced start date.
return _('TBD')
def course_end_datetime_text(end_date, format_string, strftime_localized):
"""
Returns a formatted string for a course's end date or datetime.
If end_date is None, an empty string will be returned.
Arguments:
end_date (datetime): the end datetime of a course
format_string (str): the date format type, as passed to strftime
strftime_localized ((datetime, str) -> str): a localized string
formatting function
"""
return (
_datetime_to_string(end_date, format_string, strftime_localized) if end_date is not None
else ''
)
def may_certify_for_course(certificates_display_behavior, certificates_show_before_end, has_ended):
"""
Returns whether it is acceptable to show the student a certificate download
link for a course.
Arguments:
certificates_display_behavior (str): string describing the course's
certificate display behavior.
See CourseFields.certificates_display_behavior.help for more detail.
certificates_show_before_end (bool): whether user can download the
course's certificates before the course has ended.
has_ended (bool): Whether the course has ended.
"""
show_early = (
certificates_display_behavior in ('early_with_info', 'early_no_info')
or certificates_show_before_end
)
return show_early or has_ended

View File

@@ -10,8 +10,9 @@ import requests
from datetime import datetime
import dateutil.parser
from lazy import lazy
from base64 import b32encode
from xmodule import course_metadata_utils
from xmodule.course_metadata_utils import DEFAULT_START_DATE
from xmodule.exceptions import UndefinedContext
from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.graders import grader_from_conf
@@ -29,8 +30,6 @@ log = logging.getLogger(__name__)
# Make '_' a no-op so we can scrape strings
_ = lambda text: text
DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=UTC())
CATALOG_VISIBILITY_CATALOG_AND_ABOUT = "both"
CATALOG_VISIBILITY_ABOUT = "about"
CATALOG_VISIBILITY_NONE = "none"
@@ -920,7 +919,7 @@ class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-me
"""
class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor):
class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
"""
The descriptor for the course XModule
"""
@@ -1089,20 +1088,20 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor):
Returns True if the current time is after the specified course end date.
Returns False if there is no end date specified.
"""
if self.end is None:
return False
return datetime.now(UTC()) > self.end
return course_metadata_utils.has_course_ended(self.end)
def may_certify(self):
"""
Return True if it is acceptable to show the student a certificate download link
Return whether it is acceptable to show the student a certificate download link.
"""
show_early = self.certificates_display_behavior in ('early_with_info', 'early_no_info') or self.certificates_show_before_end
return show_early or self.has_ended()
return course_metadata_utils.may_certify_for_course(
self.certificates_display_behavior,
self.certificates_show_before_end,
self.has_ended()
)
def has_started(self):
return datetime.now(UTC()) > self.start
return course_metadata_utils.has_course_started(self.start)
@property
def grader(self):
@@ -1361,36 +1360,13 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor):
then falls back to .start
"""
i18n = self.runtime.service(self, "i18n")
_ = i18n.ugettext
strftime = i18n.strftime
def try_parse_iso_8601(text):
try:
result = Date().from_json(text)
if result is None:
result = text.title()
else:
result = strftime(result, format_string)
if format_string == "DATE_TIME":
result = self._add_timezone_string(result)
except ValueError:
result = text.title()
return result
if isinstance(self.advertised_start, basestring):
return try_parse_iso_8601(self.advertised_start)
elif self.start_date_is_still_default:
# Translators: TBD stands for 'To Be Determined' and is used when a course
# does not yet have an announced start date.
return _('TBD')
else:
when = self.advertised_start or self.start
if format_string == "DATE_TIME":
return self._add_timezone_string(strftime(when, format_string))
return strftime(when, format_string)
return course_metadata_utils.course_start_datetime_text(
self.start,
self.advertised_start,
format_string,
i18n.ugettext,
i18n.strftime
)
@property
def start_date_is_still_default(self):
@@ -1398,26 +1374,20 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor):
Checks if the start date set for the course is still default, i.e. .start has not been modified,
and .advertised_start has not been set.
"""
return self.advertised_start is None and self.start == CourseFields.start.default
return course_metadata_utils.course_start_date_is_default(
self.start,
self.advertised_start
)
def end_datetime_text(self, format_string="SHORT_DATE"):
"""
Returns the end date or date_time for the course formatted as a string.
If the course does not have an end date set (course.end is None), an empty string will be returned.
"""
if self.end is None:
return ''
else:
strftime = self.runtime.service(self, "i18n").strftime
date_time = strftime(self.end, format_string)
return date_time if format_string == "SHORT_DATE" else self._add_timezone_string(date_time)
def _add_timezone_string(self, date_time):
"""
Adds 'UTC' string to the end of start/end date and time texts.
"""
return date_time + u" UTC"
return course_metadata_utils.course_end_datetime_text(
self.end,
format_string,
self.runtime.service(self, "i18n").strftime
)
def get_discussion_blackout_datetimes(self):
"""
@@ -1458,7 +1428,15 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor):
@property
def number(self):
return self.location.course
"""
Returns this course's number.
This is a "number" in the sense of the "course numbers" that you see at
lots of universities. For example, given a course
"Intro to Computer Science" with the course key "edX/CS-101/2014", the
course number would be "CS-101"
"""
return course_metadata_utils.number_for_course_location(self.location)
@property
def display_number_with_default(self):
@@ -1499,9 +1477,7 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor):
Returns a unique deterministic base32-encoded ID for the course.
The optional padding_char parameter allows you to override the "=" character used for padding.
"""
return "course_{}".format(
b32encode(unicode(self.location.course_key)).replace('=', padding_char)
)
return course_metadata_utils.clean_course_key(self.location.course_key, padding_char)
@property
def teams_enabled(self):

View File

@@ -1,8 +1,55 @@
// capa - styling
// ====================
// Table of Contents
// * +Variables - Capa
// * +Extends - Capa
// * +Mixins - Status Icon - Capa
// * +Resets - Deprecate Please
// * +Problem - Base
// * +Problem - Choice Group
// * +Problem - Misc, Unclassified Mess
// * +Problem - Text Input, Numerical Input
// * +Problem - Option Input (Dropdown)
// * +Problem - CodeMirror
// * +Problem - Misc, Unclassified Mess Part 2
// * +Problem - Rubric
// * +Problem - Annotation
// * +Problem - Choice Text Group
// * +Problem - Image Input Overrides
// +Variables - Capa
// ====================
$annotation-yellow: rgba(255,255,10,0.3);
$color-copy-tip: rgb(100,100,100);
$color-success: rgb(0, 136, 1);
$color-fail: rgb(212, 64, 64);
$correct: $green-d1;
$incorrect: $red;
// +Extends - Capa
// ====================
// Duplicated from _mixins.scss due to xmodule compilation, inheritance issues
%use-font-awesome {
font-family: FontAwesome;
-webkit-font-smoothing: antialiased;
display: inline-block;
speak: none;
}
// +Mixins - Status Icon - Capa
// ====================
@mixin status-icon($color: $gray, $fontAwesomeIcon: "\f00d"){
&:after {
@extend %use-font-awesome;
@include margin-left(17px);
color: $color;
font-size: 1.2em;
content: $fontAwesomeIcon;
}
}
// +Resets - Deprecate Please
// ====================
h2 {
margin-top: 0;
margin-bottom: ($baseline*0.75);
@@ -24,12 +71,12 @@ h2 {
.feedback-hint-correct {
margin-top: ($baseline/2);
color: $color-success;
color: $correct;
}
.feedback-hint-incorrect {
margin-top: ($baseline/2);
color: $color-fail;
color: $incorrect;
}
.feedback-hint-text {
@@ -55,10 +102,9 @@ h2 {
display: block;
}
iframe[seamless]{
overflow: hidden;
padding: 0px;
padding: 0;
border: 0px none transparent;
background-color: transparent;
}
@@ -68,13 +114,16 @@ iframe[seamless]{
}
div.problem-progress {
@include padding-left($baseline/4);
@extend %t-ultralight;
display: inline-block;
padding-left: ($baseline/4);
color: #666;
color: $gray-d1;
font-weight: 100;
font-size: em(16);
}
// +Problem - Base
// ====================
div.problem {
@media print {
display: block;
@@ -89,7 +138,11 @@ div.problem {
.inline {
display: inline;
}
}
// +Problem - Choice Group
// ====================
div.problem {
.choicegroup {
@include clearfix();
min-width: 100px;
@@ -97,51 +150,98 @@ div.problem {
width: 100px;
label {
@include float(left);
@include box-sizing(border-box);
display: inline-block;
clear: both;
margin-bottom: ($baseline/4);
margin-bottom: ($baseline/2);
border: 2px solid $gray-l4;
border-radius: 3px;
padding: ($baseline/2);
width: 100%;
&.choicegroup_correct {
&:after {
margin-left: ($baseline*0.75);
content: url('../images/correct-icon.png');
@include status-icon($correct, "\f00c");
border: 2px solid $correct;
// keep green for correct answers on hover.
&:hover {
border-color: $correct;
}
}
&.choicegroup_incorrect {
&:after {
margin-left: ($baseline*0.75);
content: url('../images/incorrect-icon.png');
@include status-icon($incorrect, "\f00d");
border: 2px solid $incorrect;
// keep red for incorrect answers on hover.
&:hover {
border-color: $incorrect;
}
}
&:hover {
border: 2px solid $blue;
}
}
.indicator_container {
@include float(left);
.indicator-container {
display: inline-block;
min-height: 1px;
width: 25px;
height: 1px;
@include margin-right(15px);
}
fieldset {
@include box-sizing(border-box);
margin: 0px 0px $baseline;
@include padding-left($baseline);
@include border-left(1px solid #ddd);
}
input[type="radio"],
input[type="checkbox"] {
@include float(left);
@include margin(4px, 8px, 0, 0);
@include margin(($baseline/4) ($baseline/2) ($baseline/4) ($baseline/4));
}
text {
@include margin-left(25px);
display: inline;
margin-left: 25px;
}
}
}
// +Problem - Status Indicators
// ====================
// Summary status indicators shown after the input area
div.problem {
.indicator-container {
.status {
width: $baseline;
height: $baseline;
// CASE: correct answer
&.correct {
@include status-icon($correct, "\f00c");
}
// CASE: incorrect answer
&.incorrect {
@include status-icon($incorrect, "\f00d");
}
// CASE: unanswered
&.unanswered {
@include status-icon($gray-l4, "\f128");
}
// CASE: processing
&.processing {
}
}
}
}
// +Problem - Misc, Unclassified Mess
// ====================
div.problem {
ol.enumerate {
li {
&:before {
@@ -187,17 +287,22 @@ div.problem {
}
}
// known classes using this div: .indicator-container, moved to section above
div {
// TO-DO: Styling used by advanced capa problem types. Should be synced up to use .status class
p {
&.answer {
margin-top: -2px;
}
&.status {
margin: 8px 0 0 $baseline/2;
@include margin(8px, 0, 0, ($baseline/2));
text-indent: 100%;
white-space: nowrap;
overflow: hidden;
}
span.clarification i {
font-style: normal;
&:hover {
@@ -224,7 +329,7 @@ div.problem {
}
input {
border-color: green;
border-color: $correct;
}
}
@@ -241,7 +346,7 @@ div.problem {
}
}
&.incorrect, &.incomplete, &.ui-icon-close {
&.ui-icon-close {
p.status {
display: inline-block;
width: 20px;
@@ -250,7 +355,21 @@ div.problem {
}
input {
border-color: red;
border-color: $incorrect;
}
}
&.incorrect, &.incomplete {
p.status {
display: inline-block;
width: 20px;
height: 20px;
background: url('../images/incorrect-icon.png') center center no-repeat;
}
input {
border-color: $incorrect;
}
}
@@ -260,14 +379,14 @@ div.problem {
}
p.answer {
@include margin-left($baseline/2);
display: inline-block;
margin-bottom: 0;
margin-left: $baseline/2;
&:before {
@extend %t-strong;
display: inline;
content: "Answer: ";
font-weight: bold;
}
&:empty {
@@ -287,8 +406,8 @@ div.problem {
}
img.loading {
@include padding-left($baseline/2);
display: inline-block;
padding-left: ($baseline/2);
}
span {
@@ -303,7 +422,7 @@ div.problem {
background: #f1f1f1;
}
}
}
}
// Hides equation previews in symbolic response problems when printing
[id^='display'].equation {
@@ -312,8 +431,9 @@ div.problem {
}
}
//TO-DO: review and deprecate all these styles within span {}
span {
&.unanswered, &.ui-icon-bullet {
&.ui-icon-bullet {
display: inline-block;
position: relative;
top: 4px;
@@ -331,7 +451,7 @@ div.problem {
background: url('../images/spinner.gif') center center no-repeat;
}
&.correct, &.ui-icon-check {
&.ui-icon-check {
display: inline-block;
position: relative;
top: 3px;
@@ -349,7 +469,7 @@ div.problem {
background: url('../images/partially-correct-icon.png') center center no-repeat;
}
&.incorrect, &.incomplete, &.ui-icon-close {
&.incomplete, &.ui-icon-close {
display: inline-block;
position: relative;
top: 3px;
@@ -360,8 +480,8 @@ div.problem {
}
.reload {
float:right;
margin: $baseline/2;
@include float(right);
margin: ($baseline/2);
}
@@ -457,15 +577,6 @@ div.problem {
}
}
form.option-input {
margin: -$baseline/2 0 $baseline;
padding-bottom: $baseline;
select {
margin-right: flex-gutter();
}
}
ul {
margin-bottom: lh();
margin-left: .75em;
@@ -485,7 +596,8 @@ div.problem {
}
dl dt {
font-weight: bold;
@extend %t-strong;
}
dl dd {
@@ -531,8 +643,8 @@ div.problem {
}
th {
@extend %t-strong;
text-align: left;
font-weight: bold;
}
td {
@@ -584,6 +696,93 @@ div.problem {
white-space: pre;
}
}
}
// +Problem - Text Input, Numerical Input
// ====================
.problem {
.capa_inputtype.textline, .inputtype.formulaequationinput {
input {
@include box-sizing(border-box);
border: 2px solid $gray-l4;
border-radius: 3px;
min-width: 160px;
height: 46px;
}
> .incorrect, .correct, .unanswered {
.status {
display: inline-block;
margin-top: ($baseline/2);
background: none;
}
}
// CASE: incorrect answer
> .incorrect {
input {
border: 2px solid $incorrect;
}
.status {
@include status-icon($incorrect, "\f00d");
}
}
// CASE: correct answer
> .correct {
input {
border: 2px solid $correct;
}
.status {
@include status-icon($correct, "\f00c");
}
}
// CASE: unanswered
> .unanswered {
input {
border: 2px solid $gray-l4;
}
.status {
@include status-icon($gray-l4, "\f128");
}
}
}
}
// +Problem - Option Input (Dropdown)
// ====================
.problem {
.inputtype.option-input {
margin: (-$baseline/2) 0 $baseline;
padding-bottom: $baseline;
select {
@include margin-right($baseline/2);
}
.indicator-container {
display: inline-block;
.status.correct:after, .status.incorrect:after {
@include margin-left(0);
}
}
}
}
// +Problem - CodeMirror
// ====================
div.problem {
.CodeMirror {
border: 1px solid black;
@@ -634,7 +833,52 @@ div.problem {
.CodeMirror-scroll {
margin-right: 0px;
}
}
// +Problem - Actions
// ====================
div.problem .action {
margin-top: $baseline;
.save, .check, .show, .reset, .hint-button {
@include margin-right($baseline/2);
margin-bottom: ($baseline/2);
height: ($baseline*2);
vertical-align: middle;
text-transform: uppercase;
font-weight: 600;
}
.save {
@extend .blue-button !optional;
}
.show {
.show-label {
font-weight: 600;
font-size: 1.0em;
}
}
.submission_feedback {
// background: #F3F3F3;
// border: 1px solid #ddd;
// border-radius: 3px;
// padding: 8px 12px;
// margin-top: ($baseline/2);
@include margin-left($baseline/2);
display: inline-block;
margin-top: 8px;
color: $gray-d1;
font-style: italic;
-webkit-font-smoothing: antialiased;
}
}
// +Problem - Misc, Unclassified Mess Part 2
// ====================
div.problem {
hr {
float: none;
clear: both;
@@ -663,52 +907,12 @@ div.problem {
padding: lh();
border: 1px solid $gray-l3;
}
div.action {
margin-top: $baseline;
.save, .check, .show, .reset, .hint-button {
height: ($baseline*2);
vertical-align: middle;
font-weight: 600;
@media print {
display: none;
}
}
.save {
@extend .blue-button !optional;
}
.show {
.show-label {
font-weight: 600;
font-size: 1.0em;
}
}
.submission_feedback {
// background: #F3F3F3;
// border: 1px solid #ddd;
// border-radius: 3px;
// padding: 8px 12px;
// margin-top: ($baseline/2);
display: inline-block;
margin-top: 8px;
@include margin-left($baseline/2);
color: #666;
font-style: italic;
-webkit-font-smoothing: antialiased;
}
}
.detailed-solution {
> p:first-child {
@extend %t-strong;
color: #aaa;
text-transform: uppercase;
font-weight: bold;
font-style: normal;
font-size: 0.9em;
}
@@ -720,9 +924,9 @@ div.problem {
.detailed-targeted-feedback {
> p:first-child {
color: red;
@extend %t-strong;
color: $incorrect;
text-transform: uppercase;
font-weight: bold;
font-style: normal;
font-size: 0.9em;
}
@@ -734,9 +938,9 @@ div.problem {
.detailed-targeted-feedback-correct {
> p:first-child {
color: green;
@extend %t-strong;
color: $correct;
text-transform: uppercase;
font-weight: bold;
font-style: normal;
font-size: 0.9em;
}
@@ -777,11 +981,11 @@ div.problem {
border: 1px solid $gray-l3;
h3 {
@extend %t-strong;
padding: 9px;
border-bottom: 1px solid #e3e3e3;
background: #eee;
text-shadow: 0 1px 0 $white;
font-weight: bold;
font-size: em(16);
}
@@ -818,9 +1022,9 @@ div.problem {
margin-bottom: 12px;
h3 {
@extend %t-strong;
color: #aaa;
text-transform: uppercase;
font-weight: bold;
font-style: normal;
font-size: 0.9em;
}
@@ -877,7 +1081,7 @@ div.problem {
}
.shortform {
font-weight: bold;
@extend %t-strong;
}
.longform {
@@ -951,7 +1155,12 @@ div.problem {
}
}
}
}
// +Problem - Rubric
// ====================
div.problem {
.rubric {
tr {
margin: ($baseline/2) 0;
@@ -1004,16 +1213,20 @@ div.problem {
display: none;
}
}
}
// +Problem - Annotation
// ====================
div.problem {
.annotation-input {
margin: 0 0 1em 0;
border: 1px solid $gray-l3;
border-radius: 1em;
.annotation-header {
@extend %t-strong;
padding: .5em 1em;
border-bottom: 1px solid $gray-l3;
font-weight: bold;
}
.annotation-body { padding: .5em 1em; }
@@ -1094,16 +1307,20 @@ div.problem {
pre { background-color: $gray-l3; color: $black; }
&:before {
@extend %t-strong;
display: block;
content: "debug input value";
text-transform: uppercase;
font-weight: bold;
font-size: 1.5em;
}
}
}
}
.choicetextgroup{
// +Problem - Choice Text Group
// ====================
div.problem {
.choicetextgroup {
@extend .choicegroup;
input[type="text"]{
@@ -1114,7 +1331,7 @@ div.problem {
@extend label.choicegroup_correct;
input[type="text"] {
border-color: green;
border-color: $correct;
}
}
@@ -1134,3 +1351,24 @@ div.problem {
}
}
}
// +Problem - Image Input Overrides
// ====================
// NOTE: temporary override until image inputs use same base html structure as other common capa input types.
div.problem .imageinput.capa_inputtype {
.status {
display: inline-block;
position: relative;
top: 3px;
width: 25px;
height: 20px;
}
.correct {
background: url('../images/correct-icon.png') center center no-repeat;
}
.incorrect {
background: url('../images/incorrect-icon.png') center center no-repeat;
}
}

View File

@@ -80,7 +80,18 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor):
return u''
@classmethod
def _construct(cls, system, contents, error_msg, location):
def _construct(cls, system, contents, error_msg, location, for_parent=None):
"""
Build a new ErrorDescriptor. using ``system``.
Arguments:
system (:class:`DescriptorSystem`): The :class:`DescriptorSystem` used
to construct the XBlock that had an error.
contents (unicode): An encoding of the content of the xblock that had an error.
error_msg (unicode): A message describing the error.
location (:class:`UsageKey`): The usage key of the XBlock that had an error.
for_parent (:class:`XBlock`): Optional. The parent of this error block.
"""
if error_msg is None:
# this string is not marked for translation because we don't have
@@ -110,6 +121,7 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor):
# real scope keys
ScopeIds(None, 'error', location, location),
field_data,
for_parent=for_parent,
)
def get_context(self):
@@ -139,6 +151,7 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor):
str(descriptor),
error_msg,
location=descriptor.location,
for_parent=descriptor.get_parent() if descriptor.has_cached_parent else None
)
@classmethod

View File

@@ -0,0 +1,43 @@
<div class="problem">
<div aria-live="polite">
<div>
<span>
<p>
<p></p>
</span>
<span><section id="textbox_test_matlab_plot1_2_1" class="capa_inputtype cminput">
<textarea rows="10" cols="80" name="input_i4x-MITx-2_01x-problem-test_matlab_plot1_2_1" aria-describedby="answer_i4x-MITx-2_01x-problem-test_matlab_plot1_2_1" id="input_i4x-MITx-2_01x-problem-test_matlab_plot1_2_1" data-tabsize="4" data-mode="octave" data-linenums="true" style="display: none;">This is the MATLAB input, whatever that may be.</textarea>
<div class="grader-status" tabindex="-1">
<span id="status_test_matlab_plot1_2_1" class="processing" aria-describedby="input_test_matlab_plot1_2_1">
<span class="status sr">processing</span>
</span>
<span style="display:none;" class="xqueue" id="test_matlab_plot1_2_1">1</span>
<p class="debug">processing</p>
</div>
<span id="answer_test_matlab_plot1_2_1"></span>
<div class="external-grader-message" aria-live="polite">
Submitted. As soon as a response is returned, this message will be replaced by that feedback.
</div>
<div class="ungraded-matlab-result" aria-live="polite">
</div>
<div class="plot-button">
<input type="button" class="save" name="plot-button" id="plot_test_matlab_plot1_2_1" value="Run Code">
</div>
</section></span>
</div>
</div>
<div class="action">
<input type="hidden" name="problem_id" value="Plot a straight line">
<button class="reset" data-value="Reset">Reset<span class="sr"> your answer</span></button>
<button class="show"><span class="show-label">Show Answer</span> </button>
</div>
</div>

View File

@@ -323,7 +323,7 @@ describe 'Problem', ->
<div><p></p><span><section id="choicetextinput_1_2_1" class="choicetextinput">
<form class="choicetextgroup capa_inputtype" id="inputtype_1_2_1">
<div class="indicator_container">
<div class="indicator-container">
<span class="unanswered" style="display:inline-block;" id="status_1_2_1"></span>
</div>
<fieldset>
@@ -628,3 +628,31 @@ describe 'Problem', ->
it 'check_save_waitfor should return false', ->
$(@problem.inputs[0]).data('waitfor', ->)
expect(@problem.check_save_waitfor()).toEqual(false)
describe 'Submitting an xqueue-graded problem', ->
matlabinput_html = readFixtures('matlabinput_problem.html')
beforeEach ->
spyOn($, 'postWithPrefix').andCallFake (url, callback) ->
callback html: matlabinput_html
jasmine.Clock.useMock()
@problem = new Problem($('.xblock-student_view'))
spyOn(@problem, 'poll').andCallThrough()
@problem.render(matlabinput_html)
it 'check that we stop polling after a fixed amount of time', ->
expect(@problem.poll).not.toHaveBeenCalled()
jasmine.Clock.tick(1)
time_steps = [1000, 2000, 4000, 8000, 16000, 32000]
num_calls = 1
for time_step in time_steps
do (time_step) =>
jasmine.Clock.tick(time_step)
expect(@problem.poll.callCount).toEqual(num_calls)
num_calls += 1
# jump the next step and verify that we are not still continuing to poll
jasmine.Clock.tick(64000)
expect(@problem.poll.callCount).toEqual(6)
expect($('.capa_alert').text()).toEqual("The grading process is still running. Refresh the page to see updates.")

View File

@@ -51,14 +51,14 @@
});
});
it('callback was called', function () {
it('callback was not called', function () {
waitsFor(function () {
return state.videoPlayer.player.getPlayerState() !== STATUS.PAUSED;
}, 'Player state should be changed', WAIT_TIMEOUT);
runs(function () {
expect(state.videoPlayer.player.callStateChangeCallback)
.toHaveBeenCalled();
.not.toHaveBeenCalled();
});
});
});
@@ -85,14 +85,14 @@
});
});
it('callback was called', function () {
it('callback was not called', function () {
waitsFor(function () {
return state.videoPlayer.player.getPlayerState() !== STATUS.PLAYING;
}, 'Player state should be changed', WAIT_TIMEOUT);
runs(function () {
expect(state.videoPlayer.player.callStateChangeCallback)
.toHaveBeenCalled();
.not.toHaveBeenCalled();
});
});
});

View File

@@ -98,19 +98,11 @@ class @Problem
if @num_queued_items > 0
if window.queuePollerID # Only one poller 'thread' per Problem
window.clearTimeout(window.queuePollerID)
queuelen = @get_queuelen()
window.queuePollerID = window.setTimeout(@poll, queuelen*10)
window.queuePollerID = window.setTimeout(
=> @poll(1000),
1000)
# Retrieves the minimum queue length of all queued items
get_queuelen: =>
minlen = Infinity
@queued_items.each (index, qitem) ->
len = parseInt($.text(qitem))
if len < minlen
minlen = len
return minlen
poll: =>
poll: (prev_timeout) =>
$.postWithPrefix "#{@url}/problem_get", (response) =>
# If queueing status changed, then render
@new_queued_items = $(response.html).find(".xqueue")
@@ -125,8 +117,16 @@ class @Problem
@forceUpdate response
delete window.queuePollerID
else
# TODO: Some logic to dynamically adjust polling rate based on queuelen
window.queuePollerID = window.setTimeout(@poll, 1000)
new_timeout = prev_timeout * 2
# if the timeout is greather than 1 minute
if new_timeout >= 60000
delete window.queuePollerID
@gentle_alert gettext("The grading process is still running. Refresh the page to see updates.")
else
window.queuePollerID = window.setTimeout(
=> @poll(new_timeout),
new_timeout
)
# Use this if you want to make an ajax call on the input type object
@@ -468,9 +468,9 @@ class @Problem
# They should set handlers on each <input> to reset the whole.
formulaequationinput: (element) ->
$(element).find('input').on 'input', ->
$p = $(element).find('p.status')
$p = $(element).find('span.status')
`// Translators: the word unanswered here is about answering a problem the student must solve.`
$p.parent().removeClass().addClass "unanswered"
$p.parent().removeClass().addClass "unsubmitted"
choicegroup: (element) ->
$element = $(element)
@@ -496,9 +496,9 @@ class @Problem
textline: (element) ->
$(element).find('input').on 'input', ->
$p = $(element).find('p.status')
$p = $(element).find('span.status')
`// Translators: the word unanswered here is about answering a problem the student must solve.`
$p.parent().removeClass("correct incorrect").addClass "unanswered"
$p.parent().removeClass("correct incorrect").addClass "unsubmitted"
inputtypeSetupMethods:

View File

@@ -290,13 +290,11 @@ function () {
var PlayerState = HTML5Video.PlayerState;
if (_this.playerState === PlayerState.PLAYING) {
_this.pauseVideo();
_this.playerState = PlayerState.PAUSED;
_this.callStateChangeCallback();
_this.pauseVideo();
} else {
_this.playVideo();
_this.playerState = PlayerState.PLAYING;
_this.callStateChangeCallback();
_this.playVideo();
}
});

View File

@@ -272,12 +272,20 @@ class BulkOperationsMixin(object):
dirty = self._end_outermost_bulk_operation(bulk_ops_record, structure_key)
self._clear_bulk_ops_record(structure_key)
# The bulk op has ended. However, the signal tasks below still need to use the
# built-up bulk op information (if the signals trigger tasks in the same thread).
# So re-nest until the signals are sent.
bulk_ops_record.nest()
if emit_signals and dirty:
self.send_bulk_published_signal(bulk_ops_record, structure_key)
self.send_bulk_library_updated_signal(bulk_ops_record, structure_key)
# Signals are sent. Now unnest and clear the bulk op for good.
bulk_ops_record.unnest()
self._clear_bulk_ops_record(structure_key)
def _is_in_bulk_operation(self, course_key, ignore_case=False):
"""
Return whether a bulk operation is active on `course_key`.

View File

@@ -75,11 +75,11 @@ class SignalHandler(object):
1. We receive using the Django Signals mechanism.
2. The sender is going to be the class of the modulestore sending it.
3. Always have **kwargs in your signal handler, as new things may be added.
4. The thing that listens for the signal lives in process, but should do
3. The names of your handler function's parameters *must* be "sender" and "course_key".
4. Always have **kwargs in your signal handler, as new things may be added.
5. The thing that listens for the signal lives in process, but should do
almost no work. Its main job is to kick off the celery task that will
do the actual work.
"""
course_published = django.dispatch.Signal(providing_args=["course_key"])
library_updated = django.dispatch.Signal(providing_args=["library_key"])

Some files were not shown because too many files have changed in this diff Show More