4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/*
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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(
|
||||
{},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
45
cms/envs/test_static_optimized.py
Normal file
45
cms/envs/test_static_optimized.py
Normal 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'
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
7
common/djangoapps/django_locale/__init__.py
Normal file
7
common/djangoapps/django_locale/__init__.py
Normal 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]
|
||||
"""
|
||||
83
common/djangoapps/django_locale/middleware.py
Normal file
83
common/djangoapps/django_locale/middleware.py
Normal 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
|
||||
157
common/djangoapps/django_locale/tests.py
Normal file
157
common/djangoapps/django_locale/tests.py
Normal 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')
|
||||
131
common/djangoapps/django_locale/trans_real.py
Normal file
131
common/djangoapps/django_locale/trans_real.py
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
150
common/djangoapps/student/cookies.py
Normal file
150
common/djangoapps/student/cookies.py
Normal 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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
78
common/djangoapps/third_party_auth/admin.py
Normal file
78
common/djangoapps/third_party_auth/admin.py
Normal 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)
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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))
|
||||
181
common/djangoapps/third_party_auth/migrations/0001_initial.py
Normal file
181
common/djangoapps/third_party_auth/migrations/0001_initial.py
Normal 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']
|
||||
@@ -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
|
||||
@@ -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']
|
||||
420
common/djangoapps/third_party_auth/models.py
Normal file
420
common/djangoapps/third_party_auth/models.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
49
common/djangoapps/third_party_auth/saml.py
Normal file
49
common/djangoapps/third_party_auth/saml.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
34
common/djangoapps/third_party_auth/strategy.py
Normal file
34
common/djangoapps/third_party_auth/strategy.py
Normal 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)
|
||||
157
common/djangoapps/third_party_auth/tasks.py
Normal file
157
common/djangoapps/third_party_auth/tasks.py
Normal 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
|
||||
15
common/djangoapps/third_party_auth/tests/data/saml_key.key
Normal file
15
common/djangoapps/third_party_auth/tests/data/saml_key.key
Normal 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-----
|
||||
17
common/djangoapps/third_party_auth/tests/data/saml_key.pub
Normal file
17
common/djangoapps/third_party_auth/tests/data/saml_key.pub
Normal 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-----
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
230
common/djangoapps/third_party_auth/tests/specs/test_testshib.py
Normal file
230
common/djangoapps/third_party_auth/tests/specs/test_testshib.py
Normal 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('"currentProvider": "TestShib"', register_response.content)
|
||||
self.assertIn('"errorMessage": 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('"defaultValue": "myself@testshib.org"', register_response.content)
|
||||
self.assertIn('"defaultValue": "Me Myself And I"', 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('"currentProvider": "TestShib"', login_response.content)
|
||||
self.assertIn('"errorMessage": 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('&', '&'), 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('&', '&'), 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()
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
64
common/djangoapps/third_party_auth/tests/test_views.py
Normal file
64
common/djangoapps/third_party_auth/tests/test_views.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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=''):
|
||||
"""
|
||||
|
||||
@@ -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')),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
\[\]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
211
common/lib/xmodule/xmodule/course_metadata_utils.py
Normal file
211
common/lib/xmodule/xmodule/course_metadata_utils.py
Normal 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('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
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
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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.")
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user