Commit 10ed05ee by Tyler Hallada

Merge remote-tracking branch 'origin/master' into EDUCATOR-926

parents 750f30b2 2f0e4ece
......@@ -284,3 +284,4 @@ Ivan Ivić <iivic@edx.org>
Brandon Baker <bcbaker@wesleyan.edu>
Salah Alomari <salomari@qrf.org>
Shirley He <she@edx.org>
Po Tsui <potsui@stanford.edu>
......@@ -29,6 +29,7 @@ dependencies:
- pip install --exists-action w pbr==0.9.0
- pip install --exists-action w -r requirements/edx/base.txt
- pip install --exists-action w -r requirements/edx/paver.txt
- pip install --exists-action w -r requirements/edx/testing.txt
- if [ -e requirements/edx/post.txt ]; then pip install --exists-action w -r requirements/edx/post.txt ; fi
- pip install coveralls==1.0
......
......@@ -514,6 +514,31 @@ class GetUserPartitionInfoTest(ModuleStoreTestCase):
"deleted": True
})
def test_singular_deleted_group(self):
"""
Verify that a partition with only one deleted group is
shown in the partition info with the group marked as deleted
"""
self._set_partitions([
UserPartition(
id=0,
name="Cohort user partition",
scheme=UserPartition.get_scheme("cohort"),
description="Cohorted user partition",
groups=[],
),
])
self._set_group_access({0: [1]})
partitions = self._get_partition_info()
groups = partitions[0]["groups"]
self.assertEqual(len(groups), 1)
self.assertEqual(groups[0], {
"id": 1,
"name": "Deleted Group",
"selected": True,
"deleted": True,
})
def test_filter_by_partition_scheme(self):
partitions = self._get_partition_info(schemes=["random"])
self.assertEqual(len(partitions), 1)
......
......@@ -391,17 +391,18 @@ def get_user_partition_info(xblock, schemes=None, course=None):
for p in sorted(get_all_partitions_for_course(course, active_only=True), key=lambda p: p.name):
# Exclude disabled partitions, partitions with no groups defined
# The exception to this case is when there is a selected group within that partition, which means there is
# a deleted group
# Also filter by scheme name if there's a filter defined.
if p.groups and (schemes is None or p.scheme.name in schemes):
selected_groups = set(xblock.group_access.get(p.id, []) or [])
if (p.groups or selected_groups) and (schemes is None or p.scheme.name in schemes):
# First, add groups defined by the partition
groups = []
for g in p.groups:
# Falsey group access for a partition mean that all groups
# are selected. In the UI, though, we don't show the particular
# groups selected, since there's a separate option for "all users".
selected_groups = set(xblock.group_access.get(p.id, []) or [])
groups.append({
"id": g.id,
"name": g.name,
......
......@@ -464,10 +464,16 @@ def _accessible_libraries_iter(user, org=None):
"""
List all libraries available to the logged in user by iterating through all libraries.
If 'org' is present, only libraries from that org will be returned.
org (string): if not None, this value will limit the libraries returned. An empty
string will result in no libraries, and otherwise only libraries with the
specified org will be returned. The default value is None.
"""
if org is not None:
libraries = [] if org == '' else modulestore().get_libraries(org=org)
else:
libraries = modulestore().get_libraries()
# No need to worry about ErrorDescriptors - split's get_libraries() never returns them.
return (lib for lib in modulestore().get_libraries(org=org) if has_studio_read_access(user, lib.location.library_key))
return (lib for lib in libraries if has_studio_read_access(user, lib.location.library_key))
@login_required
......
......@@ -19,7 +19,7 @@ class TextbookIndexTestCase(CourseTestCase):
self.assertEqual(resp.status_code, 200)
# we don't have resp.context right now,
# due to bugs in our testing harness :(
if resp.context:
if resp.context and resp.context.get('course'):
self.assertEqual(resp.context['course'], self.course)
def test_view_index_xhr(self):
......
......@@ -312,8 +312,11 @@ TEMPLATES = [
# Options specific to this backend.
'OPTIONS': {
'loaders': (
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
# We have to use mako-aware template loaders to be able to include
# mako templates inside django templates (such as main_django.html).
'openedx.core.djangoapps.theming.template_loaders.ThemeTemplateLoader',
'edxmako.makoloader.MakoFilesystemLoader',
'edxmako.makoloader.MakoAppDirectoriesLoader',
),
'context_processors': (
'django.template.context_processors.request',
......
......@@ -90,8 +90,14 @@ DEBUG_TOOLBAR_CONFIG = {
}
def should_show_debug_toolbar(_):
return True # We always want the toolbar on devstack regardless of IP, auth, etc.
def should_show_debug_toolbar(request):
# We always want the toolbar on devstack unless running tests from another Docker container
if request.get_host().startswith('edx.devstack.studio:'):
return False
# Only display for non-ajax requests.
if request.is_ajax():
return False
return True
# To see stacktraces for MongoDB queries, set this to True.
......
......@@ -10,7 +10,7 @@ LOGGING['handlers']['local'] = LOGGING['handlers']['tracking'] = {
LOGGING['loggers']['tracking']['handlers'] = ['console']
LMS_BASE = 'edx.devstack.lms:18000'
CMS_BASE = 'edx.devstack.cms:18010'
CMS_BASE = 'edx.devstack.studio:18010'
LMS_ROOT_URL = 'http://{}'.format(LMS_BASE)
......
......@@ -66,6 +66,8 @@ TEST_ROOT = path('test_root')
# Want static files in the same dir for running on jenkins.
STATIC_ROOT = TEST_ROOT / "staticfiles"
INSTALLED_APPS = tuple(app for app in INSTALLED_APPS if app != 'webpack_loader')
INSTALLED_APPS += ('openedx.tests.util.webpack_loader',)
WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json"
GITHUB_REPO_ROOT = TEST_ROOT / "data"
......
define(['js/views/xblock_validation', 'js/models/xblock_validation'],
function(XBlockValidationView, XBlockValidationModel) {
'use strict';
return function(validationMessages, hasEditingUrl, isRoot, validationEle) {
return function(validationMessages, hasEditingUrl, isRoot, isUnit, validationEle) {
var model, response;
if (hasEditingUrl && !isRoot) {
validationMessages.showSummaryOnly = true;
}
response = validationMessages;
response.isUnit = isUnit;
var model = new XBlockValidationModel(validationMessages, {parse: true});
model = new XBlockValidationModel(response, {parse: true});
if (!model.get('empty')) {
new XBlockValidationView({el: validationEle, model: model, root: isRoot}).render();
......
......@@ -19,7 +19,11 @@ define(['backbone', 'gettext', 'underscore'], function(Backbone, gettext, _) {
var summary = 'summary' in response ? response.summary : {};
var messages = 'messages' in response ? response.messages : [];
if (!summary.text) {
summary.text = gettext('This component has validation issues.');
if (response.isUnit) {
summary.text = gettext('This unit has validation issues.');
} else {
summary.text = gettext('This component has validation issues.');
}
}
if (!summary.type) {
summary.type = this.WARNING;
......
define(['jquery', 'js/factories/xblock_validation', 'common/js/spec_helpers/template_helpers'],
function($, XBlockValidationFactory, TemplateHelpers) {
describe('XBlockValidationFactory', function() {
var messageDiv;
var $messageDiv;
beforeEach(function() {
TemplateHelpers.installTemplate('xblock-validation-messages');
appendSetFixtures($('<div class="messages"></div>'));
messageDiv = $('.messages');
$messageDiv = $('.messages');
});
it('Does not attach a view if messages is empty', function() {
XBlockValidationFactory({'empty': true}, false, false, messageDiv);
expect(messageDiv.children().length).toEqual(0);
XBlockValidationFactory({empty: true}, false, false, false, $messageDiv);
expect($messageDiv.children().length).toEqual(0);
});
it('Does attach a view if messages are not empty', function() {
XBlockValidationFactory({'empty': false}, false, false, messageDiv);
expect(messageDiv.children().length).toEqual(1);
XBlockValidationFactory({empty: false}, false, false, false, $messageDiv);
expect($messageDiv.children().length).toEqual(1);
});
it('Passes through the root property to the view.', function() {
......@@ -29,12 +29,12 @@ define(['jquery', 'js/factories/xblock_validation', 'common/js/spec_helpers/temp
'xblock_id': 'id'
};
// Root is false, will not add noContainerContent.
XBlockValidationFactory(notConfiguredMessages, true, false, messageDiv);
expect(messageDiv.find('.validation')).not.toHaveClass(noContainerContent);
XBlockValidationFactory(notConfiguredMessages, true, false, false, $messageDiv);
expect($messageDiv.find('.validation')).not.toHaveClass(noContainerContent);
// Root is true, will add noContainerContent.
XBlockValidationFactory(notConfiguredMessages, true, true, messageDiv);
expect(messageDiv.find('.validation')).toHaveClass(noContainerContent);
XBlockValidationFactory(notConfiguredMessages, true, true, false, $messageDiv);
expect($messageDiv.find('.validation')).toHaveClass(noContainerContent);
});
describe('Controls display of detailed messages based on url and root property', function() {
......@@ -50,25 +50,25 @@ define(['jquery', 'js/factories/xblock_validation', 'common/js/spec_helpers/temp
});
checkDetailedMessages = function(expectedDetailedMessages) {
expect(messageDiv.children().length).toEqual(1);
expect(messageDiv.find('.xblock-message-item').length).toBe(expectedDetailedMessages);
expect($messageDiv.children().length).toEqual(1);
expect($messageDiv.find('.xblock-message-item').length).toBe(expectedDetailedMessages);
};
it('Does not show details if xblock has an editing URL and it is not rendered as root', function() {
XBlockValidationFactory(messagesWithSummary, true, false, messageDiv);
XBlockValidationFactory(messagesWithSummary, true, false, false, $messageDiv);
checkDetailedMessages(0);
});
it('Shows details if xblock does not have its own editing URL, regardless of root value', function() {
XBlockValidationFactory(messagesWithSummary, false, false, messageDiv);
XBlockValidationFactory(messagesWithSummary, false, false, false, $messageDiv);
checkDetailedMessages(2);
XBlockValidationFactory(messagesWithSummary, false, true, messageDiv);
XBlockValidationFactory(messagesWithSummary, false, true, false, $messageDiv);
checkDetailedMessages(2);
});
it('Shows details if xblock has its own editing URL and is rendered as root', function() {
XBlockValidationFactory(messagesWithSummary, true, true, messageDiv);
XBlockValidationFactory(messagesWithSummary, true, true, false, $messageDiv);
checkDetailedMessages(2);
});
});
......
......@@ -3,7 +3,7 @@
// -----------------------------
// Bootstrap theme
@import 'bootstrap/theme';
@import 'cms/bootstrap/theme';
@import 'bootstrap/scss/bootstrap';
// Variables
......
......@@ -72,6 +72,60 @@
}
}
.page-banner {
max-width: $fg-max-width;
margin: 0 auto;
.user-messages {
padding-top: $baseline;
// Hack: force override the global important rule
// that courseware links don't have an underline.
a:hover {
color: $link-color;
text-decoration: underline !important;
}
}
.alert {
margin-bottom: $baseline !important;
padding: $baseline;
border: 1px solid;
.icon-alert {
margin-right: $baseline / 4;
}
&.alert-info {
color: $state-info-text;
background-color: $state-info-bg;
border-color: $state-info-border;
box-shadow: none;
}
&.alert-success {
color: $state-success-text;
background-color: $state-success-bg;
border-color: $state-success-border;
box-shadow: none;
}
&.alert-warning {
color: $state-warning-text;
background-color: $state-warning-bg;
border-color: $state-warning-border;
box-shadow: none;
}
&.alert-danger {
color: $state-danger-text;
background-color: $state-danger-bg;
border-color: $state-danger-border;
box-shadow: none;
}
}
}
.alert, .notification, .prompt {
// types - confirm
......
......@@ -241,6 +241,7 @@ $ui-action-primary-color-focus: $blue-s1 !default;
$ui-link-color: $blue-u2 !default;
$ui-link-color-focus: $blue-s1 !default;
$link-color: $ui-link-color;
// +Specific UI
// ====================
......@@ -281,3 +282,23 @@ $action-primary-active-bg: #1AA1DE !default; // $m-blue
$very-light-text: $white !default;
$color-background-alternate: rgb(242, 248, 251) !default;
// ----------------------------
// #COLORS- Bootstrap-style
// ----------------------------
$state-success-text: $black !default;
$state-success-bg: #dff0d8 !default;
$state-success-border: darken($state-success-bg, 5%) !default;
$state-info-text: $black !default;
$state-info-bg: #d9edf7 !default;
$state-info-border: darken($state-info-bg, 7%) !default;
$state-warning-text: $black !default;
$state-warning-bg: #fcf8e3 !default;
$state-warning-border: darken($state-warning-bg, 5%) !default;
$state-danger-text: $black !default;
$state-danger-bg: #f2dede !default;
$state-danger-border: darken($state-danger-bg, 5%) !default;
......@@ -9,9 +9,11 @@
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
)
from openedx.core.djangolib.markup import HTML
%>
<%page expression_filter="h"/>
......@@ -62,6 +64,8 @@ from openedx.core.djangolib.js_utils import (
<%static:js group='base_vendor'/>
<%static:webpack entry="commons"/>
<script type="text/javascript">
window.baseUrl = "${settings.STATIC_URL | n, js_escaped_string}";
require.config({
......@@ -73,17 +77,34 @@ from openedx.core.djangolib.js_utils import (
<!-- view -->
<div class="wrapper wrapper-view" dir="${static.dir_rtl()}">
<% online_help_token = self.online_help_token() if hasattr(self, 'online_help_token') else None %>
<%include file="widgets/header.html" args="online_help_token=online_help_token" />
<% online_help_token = self.online_help_token() if hasattr(self, 'online_help_token') else None %>
<%include file="widgets/header.html" args="online_help_token=online_help_token" />
<%
banner_messages = list(PageLevelMessages.user_messages(request))
%>
% if banner_messages:
<div class="page-banner">
<div class="user-messages">
% for message in banner_messages:
<div class="alert ${message.css_class}" role="alert">
<span class="icon icon-alert fa ${message.icon_class}" aria-hidden="true"></span>
${HTML(message.message_html)}
</div>
% endfor
</div>
</div>
% endif
<div id="page-alert">
<%block name="page_alert"></%block>
</div>
<main id="main" aria-label="Content" tabindex="-1">
<div id="content">
<%block name="content"></%block>
</div>
<div id="content" class="content">
<%block name="content"></%block>
</div>
</main>
% if user.is_authenticated():
......
## mako
<%page expression_filter="h"/>
## Override the default styles_version to use Bootstrap
<%! main_css = "css/bootstrap/studio-main.css" %>
<%inherit file="../base.html" />
<%block name="title">${page_title if page_title else ''}</%block>
<%block name="content">
<%include file="/fragments/standalone-page-fragment.html" args="fragment=fragment"/>
</%block>
<%! from openedx.core.djangolib.markup import HTML %>
<%block name="head_extra">
${HTML(fragment.head_html())}
</%block>
<%block name="footer_extra">
${HTML(fragment.foot_html())}
</%block>
<div class="wrapper-content wrapper">
<section class="content">
${HTML(fragment.body_html())}
</section>
</div>
## mako
<%page expression_filter="h"/>
<%inherit file="../base.html" />
<%block name="title">${page_title if page_title else ''}</%block>
<%block name="content">
<%include file="/fragments/standalone-page-fragment.html" args="fragment=fragment"/>
</%block>
## mako
<%page expression_filter="h"/>
## Override the default styles_version to the Pattern Library version (version 2)
<%! main_css = "style-main-v2" %>
<%inherit file="../base.html" />
<%block name="title">${page_title if page_title else ''}</%block>
<%block name="content">
<%include file="/fragments/standalone-page-fragment.html" args="fragment=fragment"/>
</%block>
......@@ -3,6 +3,7 @@
from django.utils.translation import ugettext as _
from contentstore.views.helpers import xblock_studio_url
from contentstore.utils import is_visible_to_specific_partition_groups
from lms.lib.utils import is_unit
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
)
......@@ -14,6 +15,7 @@ section_class = "level-nesting" if show_inline else "level-element"
collapsible_class = "is-collapsible" if xblock.has_children else ""
label = xblock.display_name_with_default or xblock.scope_ids.block_type
messages = xblock.validate().to_json()
block_is_unit = is_unit(xblock)
%>
<%namespace name='static' file='static_content.html'/>
......@@ -30,6 +32,7 @@ messages = xblock.validate().to_json()
${messages | n, dump_js_escaped_json},
${bool(xblock_url) | n, dump_js_escaped_json}, // xblock_url will be None or a string
${bool(is_root) | n, dump_js_escaped_json}, // is_root will be None or a boolean
${bool(block_is_unit) | n, dump_js_escaped_json}, // block_is_unit will be None or a boolean
$('div.xblock-validation-messages[data-locator="${xblock.location | n, js_escaped_string}"]')
);
});
......
## Override the default styles_version to use Bootstrap
<%! main_css = "css/bootstrap/studio-main.css" %>
## mako
<%page expression_filter="h"/>
<%!
from openedx.core.djangoapps.util.user_messages import (
register_error_message,
register_info_message,
register_success_message,
register_warning_message,
)
%>
<%
register_info_message(request, _('This is a test message'))
%>
## Override the default styles_version to use Bootstrap
<%! main_css = "css/bootstrap/studio-main.css" %>
<%inherit file="/base.html" />
<%block name="title">Bootstrap Test Page</%block>
......
......@@ -4,6 +4,7 @@ from django.conf import settings
from django.utils.translation import ugettext as _
from contentstore.utils import ancestor_has_staff_lock, get_visibility_partition_info
from openedx.core.djangolib.markup import HTML, Text
from lms.lib.utils import is_unit
partition_info = get_visibility_partition_info(xblock)
selectable_partitions = partition_info["selectable_partitions"]
......@@ -11,18 +12,30 @@ selected_partition_index = partition_info["selected_partition_index"]
selected_groups_label = partition_info["selected_groups_label"]
is_staff_locked = ancestor_has_staff_lock(xblock)
block_is_unit = is_unit(xblock)
%>
<div class="modal-section visibility-summary">
% if len(selectable_partitions) == 0:
<div class="is-not-configured has-actions">
<h3 class="title">${_('Access is not restricted')}</h3>
<div class="copy">
<p>${_('Access to this component is not restricted, but visibility might be affected by inherited settings.')}</p>
% if block_is_unit:
<p>${_('Access to this unit is not restricted, but visibility might be affected by inherited settings.')}</p>
% else:
<p>${_('Access to this component is not restricted, but visibility might be affected by inherited settings.')}</p>
%endif
% if settings.FEATURES.get('ENABLE_ENROLLMENT_TRACK_USER_PARTITION'):
<p>${_('You can restrict access to this component to learners in specific enrollment tracks or content groups.')}</p>
% if block_is_unit:
<p>${_('You can restrict access to this unit to learners in specific enrollment tracks or content groups.')}</p>
% else:
<p>${_('You can restrict access to this component to learners in specific enrollment tracks or content groups.')}</p>
% endif
% else:
<p>${_('You can restrict access to this component to learners in specific content groups.')}</p>
% if block_is_unit:
<p>${_('You can restrict access to this unit to learners in specific content groups.')}</p>
% else:
<p>${_('You can restrict access to this component to learners in specific content groups.')}</p>
% endif
% endif
</div>
......@@ -98,7 +111,9 @@ is_staff_locked = ancestor_has_staff_lock(xblock)
% if group["deleted"]:
<label for="visibility-group-${partition["id"]}-${group["id"]}" class="label">
${_("Deleted Group")}
<span class="note">${_('This group no longer exists. Choose another group or do not restrict access to this component.')}</span>
<span class="note">
${_('This group no longer exists. Choose another group or remove the access restriction.')}
</span>
</label>
% else:
<label for="visibility-group-${partition["id"]}-${group["id"]}" class="label">${group["name"]}</label>
......
......@@ -64,6 +64,9 @@ urlpatterns = patterns(
# Darklang View to change the preview language (or dark language)
url(r'^update_lang/', include('openedx.core.djangoapps.dark_lang.urls', namespace='dark_lang')),
# URLs for managing theming
url(r'^theming/', include('openedx.core.djangoapps.theming.urls', namespace='theming')),
# For redirecting to help pages.
url(r'^help_token/', include('help_tokens.urls')),
)
......
......@@ -94,21 +94,18 @@ source, template_path = Loader(engine).load_template_source(path)
<%
from django.template import Template, Context
from webpack_loader.exceptions import WebpackLoaderBadStatsError
try:
return Template("""
{% load render_bundle from webpack_loader %}
{% render_bundle "commons" %}
{% render_bundle entry %}
<script type="text/javascript">
{% autoescape off %}{{ body }}{% endautoescape %}
</script>
""").render(Context({
'entry': entry,
'body': capture(caller.body)
}))
except (IOError, WebpackLoaderBadStatsError) as e:
# Don't break Mako template rendering if the bundle or webpack-stats can't be found, but log it
logger.error('[LEARNER-1938] {error}'.format(error=e))
return Template("""
{% load render_bundle from webpack_loader %}
{% render_bundle entry %}
{% if body %}
<script type="text/javascript">
{% autoescape off %}{{ body }}{% endautoescape %}
</script>
{% endif %}
""").render(Context({
'entry': entry,
'body': capture(caller.body)
}))
%>
</%def>
......
......@@ -15,7 +15,6 @@ from student.models import (
CourseEnrollmentAllowed,
DashboardConfiguration,
LinkedInAddToProfileConfiguration,
LogoutViewConfiguration,
PendingNameChange,
Registration,
RegistrationCookieConfiguration,
......@@ -203,7 +202,6 @@ admin.site.register(CourseEnrollmentAllowed)
admin.site.register(Registration)
admin.site.register(PendingNameChange)
admin.site.register(DashboardConfiguration, ConfigurationModelAdmin)
admin.site.register(LogoutViewConfiguration, ConfigurationModelAdmin)
admin.site.register(RegistrationCookieConfiguration, ConfigurationModelAdmin)
......
......@@ -2469,7 +2469,7 @@ class UserAttribute(TimeStampedModel):
class LogoutViewConfiguration(ConfigurationModel):
""" Configuration for the logout view. """
""" DEPRECATED: Configuration for the logout view. """
def __unicode__(self):
"""Unicode representation of the instance. """
......
......@@ -18,7 +18,7 @@ from pyquery import PyQuery as pq
from student.cookies import get_user_info_cookie_data
from student.helpers import DISABLE_UNENROLL_CERT_STATES
from student.models import CourseEnrollment, LogoutViewConfiguration, UserProfile
from student.models import CourseEnrollment, UserProfile
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
......@@ -126,7 +126,6 @@ class LogoutTests(TestCase):
super(LogoutTests, self).setUp()
self.user = UserFactory()
self.client.login(username=self.user.username, password=PASSWORD)
LogoutViewConfiguration.objects.create(enabled=True)
def create_oauth_client(self):
""" Creates a trusted OAuth client. """
......@@ -171,20 +170,6 @@ class LogoutTests(TestCase):
response = self.client.get(url)
self.assertRedirects(response, '/courses', fetch_redirect_response=False)
def test_switch_default(self):
""" Verify the IDA logout functionality is disabled if the associated switch is disabled. """
LogoutViewConfiguration.objects.create(enabled=False)
oauth_client = self.create_oauth_client()
self.authenticate_with_oauth(oauth_client)
self.assert_logout_redirects_to_root()
def test_switch_with_redirect_url(self):
""" Verify the IDA logout functionality is disabled if the associated switch is disabled. """
LogoutViewConfiguration.objects.create(enabled=False)
oauth_client = self.create_oauth_client()
self.authenticate_with_oauth(oauth_client)
self.assert_logout_redirects_with_target()
def test_without_session_value(self):
""" Verify logout works even if the session does not contain an entry with
the authenticated OpenID Connect clients."""
......
......@@ -108,7 +108,6 @@ from student.models import (
DashboardConfiguration,
LinkedInAddToProfileConfiguration,
LoginFailures,
LogoutViewConfiguration,
ManualEnrollmentAudit,
PasswordHistory,
PendingEmailChange,
......@@ -211,7 +210,7 @@ def index(request, extra_context=None, user=AnonymousUser()):
# Add marketable programs to the context if the multi-tenant programs switch is enabled.
if waffle.switch_is_active('get-multitenant-programs'):
programs_list = get_programs_with_type(include_hidden=False)
programs_list = get_programs_with_type(request.site, include_hidden=False)
context['programs_list'] = programs_list
......@@ -1680,7 +1679,7 @@ def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument
log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))
def _do_create_account(form, custom_form=None, site=None):
def _do_create_account(form, custom_form=None):
"""
Given cleaned post variables, create the User and UserProfile objects, as well as the
registration for this user.
......@@ -1721,12 +1720,13 @@ def _do_create_account(form, custom_form=None, site=None):
custom_model = custom_form.save(commit=False)
custom_model.user = user
custom_model.save()
if site:
# Set UserAttribute indicating the site the user account was created on.
UserAttribute.set_user_attribute(user, 'created_on_site', site.domain)
except IntegrityError:
# Figure out the cause of the integrity error
# TODO duplicate email is already handled by form.errors above as a ValidationError.
# The checks for duplicate email/username should occur in the same place with an
# AccountValidationError and a consistent user message returned (i.e. both should
# return "It looks like {username} belongs to an existing account. Try again with a
# different username.")
if len(User.objects.filter(username=user.username)) > 0:
raise AccountValidationError(
_("An account with the Public Username '{username}' already exists.").format(username=user.username),
......@@ -1767,6 +1767,13 @@ def _do_create_account(form, custom_form=None, site=None):
return (user, profile, registration)
def _create_or_set_user_attribute_created_on_site(user, site):
# Create or Set UserAttribute indicating the microsite site the user account was created on.
# User maybe created on 'courses.edx.org', or a white-label site
if site:
UserAttribute.set_user_attribute(user, 'created_on_site', site.domain)
def create_account_with_params(request, params):
"""
Given a request and a dict of parameters (which may or may not have come
......@@ -1793,6 +1800,14 @@ def create_account_with_params(request, params):
* The user-facing text is rather unfriendly (e.g. "Username must be a
minimum of two characters long" rather than "Please use a username of
at least two characters").
* Duplicate email raises a ValidationError (rather than the expected
AccountValidationError). Duplicate username returns an inconsistent
user message (i.e. "An account with the Public Username '{username}'
already exists." rather than "It looks like {username} belongs to an
existing account. Try again with a different username.") The two checks
occur at different places in the code; as a result, registering with
both a duplicate username and email raises only a ValidationError for
email only.
"""
# Copy params so we can modify it; we can't just do dict(params) because if
# params is request.POST, that results in a dict containing lists of values
......@@ -1873,7 +1888,7 @@ def create_account_with_params(request, params):
# Perform operations within a transaction that are critical to account creation
with transaction.atomic():
# first, create the account
(user, profile, registration) = _do_create_account(form, custom_form, site=request.site)
(user, profile, registration) = _do_create_account(form, custom_form)
# If a 3rd party auth provider and credentials were provided in the API, link the account with social auth
# (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
......@@ -1910,6 +1925,8 @@ def create_account_with_params(request, params):
raise ValidationError({'access_token': [error_message]})
# Perform operations that are non-critical parts of account creation
_create_or_set_user_attribute_created_on_site(user, request.site)
preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())
if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'):
......@@ -2230,7 +2247,7 @@ def auto_auth(request):
# If successful, this will return a tuple containing
# the new user object.
try:
user, profile, reg = _do_create_account(form, site=request.site)
user, profile, reg = _do_create_account(form)
except (AccountValidationError, ValidationError):
# Attempt to retrieve the existing user.
user = User.objects.get(username=username)
......@@ -2257,6 +2274,8 @@ def auto_auth(request):
profile.year_of_birth = (year - age_limit) - 1
profile.save()
_create_or_set_user_attribute_created_on_site(user, request.site)
# Enroll the user in a course
course_key = None
if course_id:
......@@ -2851,7 +2870,7 @@ class LogoutView(TemplateView):
logout(request)
# If we don't need to deal with OIDC logouts, just redirect the user.
if LogoutViewConfiguration.current().enabled and self.oauth_client_ids:
if self.oauth_client_ids:
response = super(LogoutView, self).dispatch(request, *args, **kwargs)
else:
response = redirect(self.target)
......
......@@ -15,6 +15,9 @@ _MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware',
)
_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
_SOCIAL_AUTH_AZUREAD_OAUTH2_AUTH_EXTRA_ARGUMENTS = {
'msafed': 0
}
def apply_settings(django_settings):
......@@ -35,6 +38,9 @@ def apply_settings(django_settings):
# Where to send the user once social authentication is successful.
django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL = _SOCIAL_AUTH_LOGIN_REDIRECT_URL
# Adding extra key value pair in the url query string for microsoft as per request
django_settings.SOCIAL_AUTH_AZUREAD_OAUTH2_AUTH_EXTRA_ARGUMENTS = _SOCIAL_AUTH_AZUREAD_OAUTH2_AUTH_EXTRA_ARGUMENTS
# Inject our customized auth pipeline. All auth backends must work with
# this pipeline.
django_settings.SOCIAL_AUTH_PIPELINE = [
......
......@@ -62,7 +62,6 @@ def _create_enrollment_track_partition(course):
used_ids = set(p.id for p in course.user_partitions)
if ENROLLMENT_TRACK_PARTITION_ID in used_ids:
# TODO: change to Exception after this has been in production for awhile, see TNL-6796.
log.warning(
"Can't add 'enrollment_track' partition, as ID {id} is assigned to {partition} in course {course}.".format(
id=ENROLLMENT_TRACK_PARTITION_ID,
......
......@@ -319,7 +319,7 @@ class BaseGroupConfigurationsTest(ContainerBase):
CHOOSE_ONE = "Select a group type"
CONTENT_GROUP_PARTITION = XBlockVisibilityEditorView.CONTENT_GROUP_PARTITION
ENROLLMENT_TRACK_PARTITION = XBlockVisibilityEditorView.ENROLLMENT_TRACK_PARTITION
MISSING_GROUP_LABEL = 'Deleted Group\nThis group no longer exists. Choose another group or do not restrict access to this component.'
MISSING_GROUP_LABEL = 'Deleted Group\nThis group no longer exists. Choose another group or remove the access restriction.'
VALIDATION_ERROR_LABEL = 'This component has validation issues.'
VALIDATION_ERROR_MESSAGE = "Error:\nThis component's access settings refer to deleted or invalid groups."
GROUP_VISIBILITY_MESSAGE = 'Access to some content in this unit is restricted to specific groups of learners.'
......
......@@ -10,6 +10,7 @@
# common/djangoapps/APPNAME/templates
# lms/templates
# lms/djangoapps/APPNAME/templates
# openedx/**/templates
# common/lib/capa/capa/templates
#
# Don't extract from these directory trees:
......@@ -26,6 +27,8 @@ input_encoding = utf-8
input_encoding = utf-8
[mako: common/lib/capa/capa/templates/**.html]
input_encoding = utf-8
[mako: openedx/**/templates/**.html]
input_encoding = utf-8
[mako: themes/**.html]
input_encoding = utf-8
[mako: themes/**.txt]
......
'''
django admin pages for courseware model
'''
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from ratelimitbackend import admin
from courseware.models import OfflineComputedGrade, OfflineComputedGradeLog, StudentModule
admin.site.register(StudentModule)
admin.site.register(OfflineComputedGrade)
from courseware import models
admin.site.register(OfflineComputedGradeLog)
admin.site.register(models.DynamicUpgradeDeadlineConfiguration, ConfigurationModelAdmin)
admin.site.register(models.OfflineComputedGrade)
admin.site.register(models.OfflineComputedGradeLog)
admin.site.register(models.CourseDynamicUpgradeDeadlineConfiguration, KeyedConfigurationModelAdmin)
admin.site.register(models.StudentModule)
......@@ -18,7 +18,7 @@ from courseware.date_summary import (
VerifiedUpgradeDeadlineDate
)
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module, get_module_for_descriptor
from courseware.module_render import get_module
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import Http404, QueryDict
......
......@@ -3,18 +3,21 @@ This module provides date summary blocks for the Course Info
page. Each block gives information about a particular
course-run-specific date which will be displayed to the user.
"""
from datetime import datetime
import datetime
from babel.dates import format_timedelta
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from django.utils.functional import cached_property
from django.utils.translation import get_language, to_locale, ugettext_lazy
from django.utils.translation import ugettext as _
from lazy import lazy
from pytz import timezone, utc
from course_modes.models import CourseMode
from courseware.models import CourseDynamicUpgradeDeadlineConfiguration, DynamicUpgradeDeadlineConfiguration
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import CourseEnrollment
......@@ -85,7 +88,7 @@ class DateSummary(object):
if self.date is None:
return ''
locale = to_locale(get_language())
delta = self.date - datetime.now(utc)
delta = self.date - datetime.datetime.now(utc)
try:
relative_date = format_timedelta(delta, locale=locale)
# Babel doesn't have translations for Esperanto, so we get
......@@ -115,7 +118,7 @@ class DateSummary(object):
future.
"""
if self.date is not None:
return datetime.now(utc).date() <= self.date.date()
return datetime.datetime.now(utc).date() <= self.date.date()
return False
def deadline_has_passed(self):
......@@ -124,7 +127,7 @@ class DateSummary(object):
Returns False otherwise.
"""
deadline = self.date
return deadline is not None and deadline <= datetime.now(utc)
return deadline is not None and deadline <= datetime.datetime.now(utc)
def __repr__(self):
return u'DateSummary: "{title}" {date} is_enabled={is_enabled}'.format(
......@@ -149,7 +152,7 @@ class TodaysDate(DateSummary):
@property
def date(self):
return datetime.now(utc)
return datetime.datetime.now(utc)
@property
def title(self):
......@@ -181,7 +184,7 @@ class CourseEndDate(DateSummary):
@property
def description(self):
if datetime.now(utc) <= self.date:
if datetime.datetime.now(utc) <= self.date:
mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_id)
if is_active and CourseMode.is_eligible_for_certificate(mode):
return _('To earn a certificate, you must complete all requirements before this date.')
......@@ -217,6 +220,14 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
return ecommerce_service.get_checkout_page_url(course_mode.sku)
return reverse('verify_student_upgrade_and_verify', args=(self.course_id,))
@cached_property
def enrollment(self):
return CourseEnrollment.get_enrollment(self.user, self.course_id)
@cached_property
def course_overview(self):
return CourseOverview.get_from_id(self.course_id)
@property
def is_enabled(self):
"""
......@@ -229,7 +240,12 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
if not is_enabled:
return False
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_id)
enrollment_mode = None
is_active = None
if self.enrollment:
enrollment_mode = self.enrollment.mode
is_active = self.enrollment.is_active
# Return `true` if user is not enrolled in course
if enrollment_mode is None and is_active is None:
......@@ -240,13 +256,40 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
@lazy
def date(self):
deadline = None
try:
verified_mode = CourseMode.objects.get(
course_id=self.course_id, mode_slug=CourseMode.VERIFIED
)
return verified_mode.expiration_datetime
verified_mode = CourseMode.objects.get(course_id=self.course_id, mode_slug=CourseMode.VERIFIED)
deadline = verified_mode.expiration_datetime
except CourseMode.DoesNotExist:
return None
pass
if self.course and self.course_overview.self_paced and self.enrollment:
global_config = DynamicUpgradeDeadlineConfiguration.current()
if global_config.enabled:
delta = global_config.deadline_days
# Check if the given course has opted out of the feature
course_config = CourseDynamicUpgradeDeadlineConfiguration.current(self.course.id)
if course_config.enabled:
if course_config.opt_out:
return deadline
delta = course_config.deadline_days
# This represents the first date at which the learner can access the content. This will be the
# latter of either the enrollment date or the course's start date.
content_availability_date = max(self.enrollment.created, self.course_overview.start)
user_deadline = content_availability_date + datetime.timedelta(days=delta)
# If the deadline from above is None, make sure we have a value for comparison
deadline = deadline or datetime.date.max
# The user-specific deadline should never occur after the verified mode's expiration date,
# if one is set.
deadline = min(deadline, user_deadline)
return deadline
class VerificationDeadlineDate(DateSummary):
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
import openedx.core.djangoapps.xmodule_django.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('courseware', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CourseDynamicUpgradeDeadlineConfiguration',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
('deadline_days', models.PositiveSmallIntegerField(default=21, help_text='Number of days a learner has to upgrade after content is made available')),
('opt_out', models.BooleanField(default=False, help_text='Disable the dynamic upgrade deadline for this course run.')),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
),
migrations.CreateModel(
name='DynamicUpgradeDeadlineConfiguration',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('deadline_days', models.PositiveSmallIntegerField(default=21, help_text='Number of days a learner has to upgrade after content is made available')),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
),
]
......@@ -15,10 +15,12 @@ ASSUMPTIONS: modules have unique IDs, even across different module_types
import itertools
import logging
from config_models.models import ConfigurationModel
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.utils.translation import ugettext_lazy as _
from model_utils.models import TimeStampedModel
import coursewarehistoryextended
......@@ -40,6 +42,7 @@ class ChunkingManager(models.Manager):
:class:`~Manager` that adds an additional method :meth:`chunked_filter` to provide
the ability to make select queries with specific chunk sizes.
"""
class Meta(object):
app_label = "courseware"
......@@ -130,16 +133,17 @@ class StudentModule(models.Model):
return queryset
def __repr__(self):
return 'StudentModule<%r>' % ({
'course_id': self.course_id,
'module_type': self.module_type,
# We use the student_id instead of username to avoid a database hop.
# This can actually matter in cases where we're logging many of
# these (e.g. on a broken progress page).
'student_id': self.student_id,
'module_state_key': self.module_state_key,
'state': str(self.state)[:20],
},)
return 'StudentModule<%r>' % (
{
'course_id': self.course_id,
'module_type': self.module_type,
# We use the student_id instead of username to avoid a database hop.
# This can actually matter in cases where we're logging many of
# these (e.g. on a broken progress page).
'student_id': self.student_id,
'module_state_key': self.module_state_key,
'state': str(self.state)[:20],
},)
def __unicode__(self):
return unicode(repr(self))
......@@ -267,6 +271,7 @@ class XModuleUserStateSummaryField(XBlockFieldBase):
"""
Stores data set in the Scope.user_state_summary scope by an xmodule field
"""
class Meta(object):
app_label = "courseware"
unique_together = (('usage_id', 'field_name'),)
......@@ -279,6 +284,7 @@ class XModuleStudentPrefsField(XBlockFieldBase):
"""
Stores data set in the Scope.preferences scope by an xmodule field
"""
class Meta(object):
app_label = "courseware"
unique_together = (('student', 'module_type', 'field_name'),)
......@@ -293,6 +299,7 @@ class XModuleStudentInfoField(XBlockFieldBase):
"""
Stores data set in the Scope.preferences scope by an xmodule field
"""
class Meta(object):
app_label = "courseware"
unique_together = (('student', 'field_name'),)
......@@ -310,11 +317,11 @@ class OfflineComputedGrade(models.Model):
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
updated = models.DateTimeField(auto_now=True, db_index=True)
gradeset = models.TextField(null=True, blank=True) # grades, stored as JSON
gradeset = models.TextField(null=True, blank=True) # grades, stored as JSON
class Meta(object):
app_label = "courseware"
unique_together = (('user', 'course_id'), )
unique_together = (('user', 'course_id'),)
def __unicode__(self):
return "[OfflineComputedGrade] %s: %s (%s) = %s" % (self.user, self.course_id, self.created, self.gradeset)
......@@ -325,6 +332,7 @@ class OfflineComputedGradeLog(models.Model):
Log of when offline grades are computed.
Use this to be able to show instructor when the last computed grades were done.
"""
class Meta(object):
app_label = "courseware"
ordering = ["-created"]
......@@ -332,7 +340,7 @@ class OfflineComputedGradeLog(models.Model):
course_id = CourseKeyField(max_length=255, db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
seconds = models.IntegerField(default=0) # seconds elapsed for computation
seconds = models.IntegerField(default=0) # seconds elapsed for computation
nstudents = models.IntegerField(default=0)
def __unicode__(self):
......@@ -355,3 +363,40 @@ class StudentFieldOverride(TimeStampedModel):
field = models.CharField(max_length=255)
value = models.TextField(default='null')
class DynamicUpgradeDeadlineConfiguration(ConfigurationModel):
""" Dynamic upgrade deadline configuration.
This model controls the behavior of the dynamic upgrade deadline for self-paced courses.
"""
class Meta(object):
app_label = 'courseware'
deadline_days = models.PositiveSmallIntegerField(
default=21,
help_text=_('Number of days a learner has to upgrade after content is made available')
)
class CourseDynamicUpgradeDeadlineConfiguration(ConfigurationModel):
"""
Per-course run configuration for dynamic upgrade deadlines.
This model controls dynamic upgrade deadlines on a per-course run level, allowing course runs to
have different deadlines or opt out of the functionality altogether.
"""
class Meta(object):
app_label = 'courseware'
KEY_FIELDS = ('course_id',)
course_id = CourseKeyField(max_length=255, db_index=True)
deadline_days = models.PositiveSmallIntegerField(
default=21,
help_text=_('Number of days a learner has to upgrade after content is made available')
)
opt_out = models.BooleanField(
default=False,
help_text=_('Disable the dynamic upgrade deadline for this course run.')
)
......@@ -70,10 +70,7 @@ class CourseInfoTab(CourseTab):
@classmethod
def is_enabled(cls, course, user=None):
"""
The "Home" tab is not shown for the new unified course experience.
"""
return not UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id)
return True
class SyllabusTab(EnrolledTab):
......@@ -326,6 +323,9 @@ def get_course_tab_list(request, course):
if tab.type != 'courseware':
continue
tab.name = _("Entrance Exam")
# TODO: LEARNER-611 - once the course_info tab is removed, remove this code
if UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id) and tab.type == 'course_info':
continue
if tab.type == 'static_tab' and tab.course_staff_only and \
not bool(user and has_access(user, 'staff', course, course.id)):
continue
......
......@@ -388,7 +388,7 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest
self.assertEqual(resp.status_code, 200)
def test_num_queries_instructor_paced(self):
self.fetch_course_info_with_queries(self.instructor_paced_course, 24, 3)
self.fetch_course_info_with_queries(self.instructor_paced_course, 25, 3)
def test_num_queries_self_paced(self):
self.fetch_course_info_with_queries(self.self_paced_course, 24, 3)
self.fetch_course_info_with_queries(self.self_paced_course, 25, 3)
......@@ -15,18 +15,19 @@ from courseware.courses import get_course_date_blocks
from courseware.date_summary import (
CourseEndDate,
CourseStartDate,
DateSummary,
TodaysDate,
VerificationDeadlineDate,
VerifiedUpgradeDeadlineDate
)
from courseware.models import DynamicUpgradeDeadlineConfiguration, CourseDynamicUpgradeDeadlineConfiguration
from lms.djangoapps.verify_student.models import VerificationDeadline
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from student.tests.factories import CourseEnrollmentFactory, UserFactory, TEST_PASSWORD
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
......@@ -56,12 +57,12 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
):
"""Set up the course and user for this test."""
now = datetime.now(utc)
# pylint: disable=attribute-defined-outside-init
if create_user:
self.user = UserFactory.create(username='mrrobot', password='test') # pylint: disable=attribute-defined-outside-init
self.user = UserFactory()
self.course = CourseFactory.create( # pylint: disable=attribute-defined-outside-init
start=now + timedelta(days=days_till_start)
)
self.course = CourseFactory.create(start=now + timedelta(days=days_till_start))
if days_till_end is not None:
self.course.end = now + timedelta(days=days_till_end)
......@@ -96,7 +97,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
def test_course_info_feature_flag(self):
SelfPacedConfiguration(enable_course_home_improvements=False).save()
self.setup_course_and_user()
self.client.login(username='mrrobot', password='test')
self.client.login(username=self.user.username, password=TEST_PASSWORD)
url = reverse('info', args=(self.course.id,))
response = self.client.get(url)
self.assertNotIn('date-summary', response.content)
......@@ -198,7 +199,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
def test_todays_date_no_timezone(self, url_name):
with freeze_time('2015-01-02'):
self.setup_course_and_user()
self.client.login(username='mrrobot', password='test')
self.client.login(username=self.user.username, password=TEST_PASSWORD)
html_elements = [
'<h3 class="hd hd-6 handouts-header">Important Course Dates</h3>',
......@@ -209,7 +210,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'data-string="Today is {date}"',
'data-timezone="None"'
]
url = reverse(url_name, args=(self.course.id, ))
url = reverse(url_name, args=(self.course.id,))
response = self.client.get(url, follow=True)
for html in html_elements:
self.assertContains(response, html)
......@@ -222,7 +223,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
def test_todays_date_timezone(self, url_name):
with freeze_time('2015-01-02'):
self.setup_course_and_user()
self.client.login(username='mrrobot', password='test')
self.client.login(username=self.user.username, password=TEST_PASSWORD)
set_user_preference(self.user, "time_zone", "America/Los_Angeles")
url = reverse(url_name, args=(self.course.id,))
response = self.client.get(url, follow=True)
......@@ -253,7 +254,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
def test_start_date_render(self, url_name):
with freeze_time('2015-01-02'):
self.setup_course_and_user()
self.client.login(username='mrrobot', password='test')
self.client.login(username=self.user.username, password=TEST_PASSWORD)
url = reverse(url_name, args=(self.course.id,))
response = self.client.get(url, follow=True)
html_elements = [
......@@ -271,7 +272,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
def test_start_date_render_time_zone(self, url_name):
with freeze_time('2015-01-02'):
self.setup_course_and_user()
self.client.login(username='mrrobot', password='test')
self.client.login(username=self.user.username, password=TEST_PASSWORD)
set_user_preference(self.user, "time_zone", "America/Los_Angeles")
url = reverse(url_name, args=(self.course.id,))
response = self.client.get(url, follow=True)
......@@ -389,3 +390,62 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
)
block = VerificationDeadlineDate(self.course, self.user)
self.assertEqual(block.relative_datestring, expected_date_string)
def create_self_paced_course_run(self, **kwargs):
defaults = {
'enroll_user': False,
'days_till_upgrade_deadline': 100,
}
defaults.update(kwargs)
self.setup_course_and_user(**defaults)
self.course.self_paced = True
self.store.update_item(self.course, self.user.id)
overview = CourseOverview.get_from_id(self.course.id)
self.assertTrue(overview.self_paced)
def test_date_with_self_paced(self):
""" The date returned for self-paced course runs should be dependent on the learner's enrollment date. """
global_config = DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
# Enrollments made before the course start should use the course start date as the content availability date
self.create_self_paced_course_run(days_till_start=3)
CourseEnrollmentFactory.create(course_id=self.course.id, user=self.user, mode=CourseMode.AUDIT)
block = VerifiedUpgradeDeadlineDate(self.course, self.user)
overview = CourseOverview.get_from_id(self.course.id)
expected = overview.start + timedelta(days=global_config.deadline_days)
self.assertEqual(block.date, expected)
# Enrollments made after the course start should use the enrollment date as the content availability date
self.create_self_paced_course_run(days_till_start=-1)
enrollment = CourseEnrollmentFactory.create(course_id=self.course.id, user=self.user, mode=CourseMode.AUDIT)
block = VerifiedUpgradeDeadlineDate(self.course, self.user)
expected = enrollment.created + timedelta(days=global_config.deadline_days)
self.assertEqual(block.date, expected)
# Courses should be able to override the deadline
course_config = CourseDynamicUpgradeDeadlineConfiguration.objects.create(
enabled=True, course_id=self.course.id, opt_out=False, deadline_days=3
)
block = VerifiedUpgradeDeadlineDate(self.course, self.user)
expected = enrollment.created + timedelta(days=course_config.deadline_days)
self.assertEqual(block.date, expected)
# Disabling the functionality should result in the verified mode's expiration date being returned.
global_config.enabled = False
global_config.save()
block = VerifiedUpgradeDeadlineDate(self.course, self.user)
expected = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED).expiration_datetime
self.assertEqual(block.date, expected)
def test_date_with_self_paced_with_course_opt_out(self):
""" If the course run has opted out of the dynamic deadline, the course mode's deadline should be used. """
self.create_self_paced_course_run(days_till_start=-1)
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
CourseEnrollmentFactory.create(course_id=self.course.id, user=self.user, mode=CourseMode.AUDIT)
# Opt the course out of the dynamic upgrade deadline
CourseDynamicUpgradeDeadlineConfiguration.objects.create(enabled=True, course_id=self.course.id, opt_out=True)
block = VerifiedUpgradeDeadlineDate(self.course, self.user)
expected = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED).expiration_datetime
self.assertEqual(block.date, expected)
......@@ -792,6 +792,14 @@ class CourseInfoTabTestCase(TabTestCase):
tabs = get_course_tab_list(self.request, self.course)
self.assertEqual(tabs[0].type, 'courseware')
# TODO: LEARNER-611 - remove once course_info is removed.
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_default_tab_for_displayable(self):
tabs = xmodule_tabs.CourseTabList.iterate_displayable(self.course, self.user)
for i, tab in enumerate(tabs):
if i == 0:
self.assertEqual(tab.type, 'course_info')
@attr(shard=1)
class DiscussionLinkTestCase(TabTestCase):
......
......@@ -211,8 +211,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20
@ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 142),
(ModuleStoreEnum.Type.split, 4, 142),
(ModuleStoreEnum.Type.mongo, 10, 144),
(ModuleStoreEnum.Type.split, 4, 144),
)
@ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
......@@ -1464,12 +1464,12 @@ class ProgressPageTests(ProgressPageBaseTests):
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced)
with self.assertNumQueries(39, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
with self.assertNumQueries(40, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
self._get_progress_page()
@ddt.data(
(False, 39, 25),
(True, 32, 21)
(False, 40, 26),
(True, 33, 22)
)
@ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent):
......
......@@ -148,9 +148,9 @@ class RenderXBlockTestMixin(object):
return response
@ddt.data(
('vertical_block', ModuleStoreEnum.Type.mongo, 10),
('vertical_block', ModuleStoreEnum.Type.mongo, 14),
('vertical_block', ModuleStoreEnum.Type.split, 6),
('html_block', ModuleStoreEnum.Type.mongo, 11),
('html_block', ModuleStoreEnum.Type.mongo, 15),
('html_block', ModuleStoreEnum.Type.split, 6),
)
@ddt.unpack
......
......@@ -156,7 +156,7 @@ def courses(request):
# Add marketable programs to the context if the multi-tenant programs switch is enabled.
if waffle.switch_is_active('get-multitenant-programs'):
programs_list = get_programs_with_type(include_hidden=False)
programs_list = get_programs_with_type(request.site, include_hidden=False)
return render_to_response(
"courseware/courses.html",
......
......@@ -404,8 +404,8 @@ class ViewsQueryCountTestCase(
return inner
@ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 4, 31),
(ModuleStoreEnum.Type.split, 3, 13, 31),
(ModuleStoreEnum.Type.mongo, 3, 4, 32),
(ModuleStoreEnum.Type.split, 3, 13, 32),
)
@ddt.unpack
@count_queries
......@@ -413,8 +413,8 @@ class ViewsQueryCountTestCase(
self.create_thread_helper(mock_request)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 3, 27),
(ModuleStoreEnum.Type.split, 3, 10, 27),
(ModuleStoreEnum.Type.mongo, 3, 3, 28),
(ModuleStoreEnum.Type.split, 3, 10, 28),
)
@ddt.unpack
@count_queries
......
......@@ -81,9 +81,11 @@ class ZeroSubsectionGrade(SubsectionGradeBase):
):
block = self.course_data.structure[block_key]
if getattr(block, 'has_score', False):
locations[block_key] = get_score(
problem_score = get_score(
submissions_scores={}, csm_scores={}, persisted_block=None, block=block,
)
if problem_score is not None:
locations[block_key] = problem_score
return locations
......
......@@ -367,23 +367,40 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
self.assertEqual(mock_read_saved_grade.called, feature_flag and course_setting)
@ddt.ddt
class ZeroGradeTest(GradeTestBase):
"""
Tests ZeroCourseGrade (and, implicitly, ZeroSubsectionGrade)
functionality.
"""
def test_zero(self):
@ddt.data(True, False)
def test_zero(self, assume_zero_enabled):
"""
Creates a ZeroCourseGrade and ensures it's empty.
"""
course_data = CourseData(self.request.user, structure=self.course_structure)
chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
for chapter in chapter_grades:
for section in chapter_grades[chapter]['sections']:
for score in section.problem_scores.itervalues():
self.assertEqual(score.earned, 0)
self.assertEqual(score.first_attempted, None)
self.assertEqual(section.all_total.earned, 0)
with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
course_data = CourseData(self.request.user, structure=self.course_structure)
chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
for chapter in chapter_grades:
for section in chapter_grades[chapter]['sections']:
for score in section.problem_scores.itervalues():
self.assertEqual(score.earned, 0)
self.assertEqual(score.first_attempted, None)
self.assertEqual(section.all_total.earned, 0)
@ddt.data(True, False)
def test_zero_null_scores(self, assume_zero_enabled):
"""
Creates a zero course grade and ensures that null scores aren't included in the section problem scores.
"""
with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
with patch('lms.djangoapps.grades.new.subsection_grade.get_score', return_value=None):
course_data = CourseData(self.request.user, structure=self.course_structure)
chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
for chapter in chapter_grades:
self.assertNotEqual({}, chapter_grades[chapter]['sections'])
for section in chapter_grades[chapter]['sections']:
self.assertEqual({}, section.problem_scores)
class SubsectionGradeTest(GradeTestBase):
......
......@@ -8,6 +8,7 @@ from xblock.core import XBlock
from xblock.fields import Boolean, Dict, Scope, String, XBlockMixin
from xblock.validation import ValidationMessage
from lms.lib.utils import is_unit
from xmodule.modulestore.inheritance import UserPartitionList
from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError
......@@ -15,8 +16,16 @@ from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPa
# more information can be found here: https://openedx.atlassian.net/browse/PLAT-902
_ = lambda text: text
INVALID_USER_PARTITION_VALIDATION = _(u"This component's access settings refer to deleted or invalid group configurations.")
INVALID_USER_PARTITION_GROUP_VALIDATION = _(u"This component's access settings refer to deleted or invalid groups.")
INVALID_USER_PARTITION_VALIDATION_COMPONENT = _(
u"This component's access settings refer to deleted or invalid group configurations."
)
INVALID_USER_PARTITION_VALIDATION_UNIT = _(
u"This unit's access settings refer to deleted or invalid group configurations."
)
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT = _(
u"This component's access settings refer to deleted or invalid groups."
)
INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT = _(u"This unit's access settings refer to deleted or invalid groups.")
NONSENSICAL_ACCESS_RESTRICTION = _(u"This component's access settings contradict its parent's access settings.")
......@@ -32,6 +41,7 @@ class GroupAccessDict(Dict):
@XBlock.needs('partitions')
@XBlock.needs('i18n')
class LmsBlockMixin(XBlockMixin):
"""
Mixin that defines fields common to all blocks used in the LMS
......@@ -185,6 +195,7 @@ class LmsBlockMixin(XBlockMixin):
validation = super(LmsBlockMixin, self).validate()
has_invalid_user_partitions = False
has_invalid_groups = False
block_is_unit = is_unit(self)
for user_partition_id, group_ids in self.group_access.iteritems():
try:
......@@ -204,7 +215,9 @@ class LmsBlockMixin(XBlockMixin):
validation.add(
ValidationMessage(
ValidationMessage.ERROR,
INVALID_USER_PARTITION_VALIDATION
(INVALID_USER_PARTITION_VALIDATION_UNIT
if block_is_unit
else INVALID_USER_PARTITION_VALIDATION_COMPONENT)
)
)
......@@ -212,7 +225,9 @@ class LmsBlockMixin(XBlockMixin):
validation.add(
ValidationMessage(
ValidationMessage.ERROR,
INVALID_USER_PARTITION_GROUP_VALIDATION
(INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT
if block_is_unit
else INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT)
)
)
......
......@@ -6,6 +6,7 @@ import datetime
import decimal
import json
import logging
import urllib
import analytics
import waffle
......@@ -434,6 +435,21 @@ class PayAndVerifyView(View):
return render_to_response("verify_student/pay_and_verify.html", context)
def add_utm_params_to_url(self, url):
# utm_params is [(u'utm_content', u'course-v1:IDBx IDB20.1x 1T2017'),...
utm_params = [item for item in self.request.GET.items() if 'utm_' in item[0]]
# utm_params is utm_content=course-v1%3AIDBx+IDB20.1x+1T2017&...
utm_params = urllib.urlencode(utm_params, True)
# utm_params is utm_content=course-v1:IDBx+IDB20.1x+1T2017&...
# (course-keys do not have url encoding)
utm_params = urllib.unquote(utm_params)
if utm_params:
if '?' in url:
url = url + '&' + utm_params
else:
url = url + '?' + utm_params
return url
def _redirect_if_necessary(
self, message, already_verified, already_paid, is_enrolled, course_key, # pylint: disable=bad-continuation
user_is_trying_to_pay, user, sku # pylint: disable=bad-continuation
......@@ -504,6 +520,8 @@ class PayAndVerifyView(View):
# Redirect if necessary, otherwise implicitly return None
if url is not None:
if waffle.switch_is_active('add-utm-params'):
url = self.add_utm_params_to_url(url)
return redirect(url)
def _get_paid_mode(self, course_key):
......
......@@ -582,9 +582,6 @@ TEMPLATES = [
]
DEFAULT_TEMPLATE_ENGINE = TEMPLATES[0]
# The template used to render a web fragment as a standalone page
STANDALONE_FRAGMENT_VIEW_TEMPLATE = 'fragment-view-chromeless.html'
###############################################################################################
# use the ratelimit backend to prevent brute force attacks
......@@ -1967,6 +1964,7 @@ YOUTUBE = {
YOUTUBE_API_KEY = None
################################### APPS ######################################
INSTALLED_APPS = (
# Standard ones that are always installed...
'django.contrib.auth',
......
......@@ -87,9 +87,14 @@ DEBUG_TOOLBAR_CONFIG = {
}
def should_show_debug_toolbar(_):
return True # We always want the toolbar on devstack regardless of IP, auth, etc.
def should_show_debug_toolbar(request):
# We always want the toolbar on devstack unless running tests from another Docker container
if request.get_host().startswith('edx.devstack.lms:'):
return False
# Only display for non-ajax requests.
if request.is_ajax():
return False
return True
########################### PIPELINE #################################
......
......@@ -10,7 +10,7 @@ LOGGING['handlers']['local'] = LOGGING['handlers']['tracking'] = {
LOGGING['loggers']['tracking']['handlers'] = ['console']
LMS_BASE = 'edx.devstack.lms:18000'
CMS_BASE = 'edx.devstack.cms:18010'
CMS_BASE = 'edx.devstack.studio:18010'
SITE_NAME = LMS_BASE
LMS_ROOT_URL = 'http://{}'.format(LMS_BASE)
......
......@@ -113,6 +113,8 @@ NOSE_PLUGINS = [
TEST_ROOT = path("test_root")
# Want static files in the same dir for running on jenkins.
STATIC_ROOT = TEST_ROOT / "staticfiles"
INSTALLED_APPS = tuple(app for app in INSTALLED_APPS if app != 'webpack_loader')
INSTALLED_APPS += ('openedx.tests.util.webpack_loader',)
WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json"
STATUS_MESSAGE_PATH = TEST_ROOT / "status_message.json"
......
......@@ -57,3 +57,11 @@ class LmsUtilsTest(ModuleStoreTestCase):
self.assertIsNone(utils.get_parent_unit(self.course))
self.assertIsNone(utils.get_parent_unit(self.chapter))
self.assertIsNone(utils.get_parent_unit(self.sequential))
def test_is_unit(self):
"""
Tests `is_unit` method for the successful result.
"""
self.assertFalse(utils.is_unit(self.html_module_1))
self.assertFalse(utils.is_unit(self.child_vertical))
self.assertTrue(utils.is_unit(self.vertical))
......@@ -28,3 +28,19 @@ def get_parent_unit(xblock):
if parent.category == "vertical" and grandparent.category == "sequential":
return parent
xblock = parent
def is_unit(xblock):
"""
Checks whether the xblock is a unit.
Get_parent_unit() returns None if the current xblock either does
not have a parent unit or is itself a unit.
To make sure that get_parent_unit() isn't returning None because
the xblock is an orphan, we check that the xblock has a parent.
Returns:
True if the xblock is itself a unit, False otherwise.
"""
return get_parent_unit(xblock) is None and xblock.get_parent()
......@@ -5,7 +5,11 @@ import ddt
from nose.plugins.attrib import attr
from lms_xblock.mixin import (
INVALID_USER_PARTITION_VALIDATION, INVALID_USER_PARTITION_GROUP_VALIDATION, NONSENSICAL_ACCESS_RESTRICTION
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT,
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
INVALID_USER_PARTITION_VALIDATION_UNIT,
NONSENSICAL_ACCESS_RESTRICTION
)
from xblock.validation import ValidationMessage
from xmodule.modulestore import ModuleStoreEnum
......@@ -92,14 +96,14 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
def test_validate_invalid_user_partitions(self):
"""
Test the validation messages produced for an xblock referring to non-existent user partitions.
Test the validation messages produced for a component referring to non-existent user partitions.
"""
self.set_group_access(self.video_location, {999: [self.group1.id]})
validation = self.store.get_item(self.video_location).validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_VALIDATION,
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
......@@ -111,7 +115,32 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_VALIDATION,
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
def test_validate_invalid_user_partitions_unit(self):
"""
Test the validation messages produced for a unit referring to non-existent user partitions.
"""
self.set_group_access(self.vertical_location, {999: [self.group1.id]})
validation = self.store.get_item(self.vertical_location).validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_VALIDATION_UNIT,
ValidationMessage.ERROR,
)
# Now add a second invalid user partition and validate again.
# Note that even though there are two invalid configurations,
# only a single error message will be returned.
self.set_group_access(self.vertical_location, {998: [self.group2.id]})
validation = self.store.get_item(self.vertical_location).validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_VALIDATION_UNIT,
ValidationMessage.ERROR,
)
......@@ -124,7 +153,7 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION,
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
......@@ -134,7 +163,7 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION,
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
......@@ -165,6 +194,19 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
ValidationMessage.ERROR,
)
def test_validate_invalid_groups_for_unit(self):
"""
Test the validation messages produced for a unit-level xblock referring to non-existent groups.
"""
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id, 999]})
validation = self.store.get_item(self.vertical_location).validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT,
ValidationMessage.ERROR,
)
def test_validate_nonsensical_access_restriction(self):
"""
Test the validation messages produced for a component whose
......@@ -221,7 +263,7 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
self.assertEqual(len(validation.messages), 2)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION,
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
self.verify_validation_message(
......
......@@ -18,6 +18,10 @@ body.view-in-course {
.wrapper-header {
min-width: auto;
.user-dropdown, .dropdown {
padding: ($baseline/2);
}
}
}
......
// LMS layouts
.content-wrapper {
margin-top: $baseline;
.course-tabs {
padding-bottom: none;
padding-bottom: 0;
.nav-item {
&.active, &:hover{
......
......@@ -6,6 +6,7 @@ section.outside-app {
h1 {
@extend h2;
margin-bottom: ($baseline*2);
text-align: center;
}
p, ul, form {
......
......@@ -201,7 +201,7 @@ $twitter-blue: #55ACEE;
$facebook-blue: #3B5998;
$linkedin-blue: #0077B5;
$google-red: #D73924;
$microsoft-blue: #00BCF2;
$microsoft-black: #000000;
// shadows
$shadow: rgba(0,0,0,0.2) !default;
......
......@@ -185,7 +185,7 @@
.user-dropdown, .dropdown {
font-size: $body-font-size;
padding: 0 ($baseline/2);
padding: ($baseline/4) ($baseline/2);
color: $base-font-color;
border: none;
background: $white;
......
......@@ -520,16 +520,16 @@
}
&.button-oa2-azuread-oauth2 {
color: darken($microsoft-blue, 20%);
color: $microsoft-black;
.icon {
background: $microsoft-blue;
background: $microsoft-black;
}
&:hover,
&:focus {
background-color: $microsoft-blue;
border: 1px solid $microsoft-blue;
background-color: $microsoft-black;
border: 1px solid $microsoft-black;
color: $white;
}
}
......
......@@ -3,6 +3,10 @@
<%namespace name='static' file='../static_content.html'/>
<%block name="bodyclass">courseware</%block>
<%block name="pagetitle">${_("Courseware")}</%block>
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%block name="headextra">
<%static:css group='style-course-vendor'/>
......@@ -15,13 +19,13 @@
<section class="outside-app">
<h1>
${_('There has been an error on the {platform_name} servers').format(
platform_name=u'<span class="edx">{}</span>'.format(settings.PLATFORM_NAME)
platform_name=u'<span class="edx">{}</span>'.format(static.get_platform_name())
)}
</h1>
<p>
${_("We're sorry, this module is temporarily unavailable. Our staff is working to fix "
"it as soon as possible. Please email us at {tech_support_email} to report any problems or downtime.").format(
tech_support_email=u'<a href=\"mailto:{0}\">{0}</a>'.format(settings.TECH_SUPPORT_EMAIL)
tech_support_email=u'<a href=\"mailto:{0}\">{0}</a>'.format(static.get_tech_support_email_address())
)}
</p>
</section>
......
## mako
<%! from django.utils.translation import ugettext as _ %>
<%namespace name='static' file='../static_content.html'/>
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%
tech_support_email='<a href=\"mailto:{tech_support_email}\">{tech_support_email}</a>'.format(tech_support_email=settings.TECH_SUPPORT_EMAIL)
tech_support_email='<a href=\"mailto:{tech_support_email}\">{tech_support_email}</a>'.format(tech_support_email=static.get_tech_support_email_address())
%>
<p>${_("We're sorry, this module is temporarily unavailable. Our staff is working to fix it as soon as possible. Please email us at {tech_support_email} to report any problems or downtime.").format(tech_support_email=tech_support_email)}</p>
## mako
<%page expression_filter="h"/>
## Override the default styles_version to use Bootstrap
<%! main_css = "css/bootstrap/lms-main.css" %>
<%inherit file="/main.html" />
<%block name="pagetitle">${page_title if page_title else ''}</%block>
<%include file="/fragments/standalone-page-fragment.html" args="fragment=fragment"/>
## mako
<%! main_css = "style-main-v2" %>
<%page expression_filter="h"/>
<%inherit file="/main.html" />
<%namespace name='static' file='static_content.html'/>
<%! from openedx.core.djangolib.markup import HTML %>
<% header_file = None %>
<%block name="head_extra">
${HTML(fragment.head_html())}
</%block>
......
## mako
<%page expression_filter="h"/>
<%inherit file="/main.html" />
<%block name="pagetitle">${page_title if page_title else ''}</%block>
<%include file="/fragments/standalone-page-fragment.html" args="fragment=fragment"/>
## mako
<%page expression_filter="h"/>
## Override the default styles_version to the Pattern Library version (version 2)
<%! main_css = "style-main-v2" %>
<%inherit file="/main.html" />
<%block name="pagetitle">${page_title if page_title else ''}</%block>
<%include file="/fragments/standalone-page-fragment.html" args="fragment=fragment"/>
......@@ -81,6 +81,8 @@ from pipeline_mako import render_require_js_path_overrides
<%static:js group='lms_bootstrap'/>
% endif
<%static:webpack entry="commons"/>
<script>
window.baseUrl = "${settings.STATIC_URL | n, js_escaped_string}";
(function (require) {
......
......@@ -40,6 +40,8 @@
{% endwith %}
</div>
{% load render_bundle from webpack_loader %}
{% render_bundle "commons" %}
{% javascript 'base_application' %}
......
<%! from django.utils.translation import ugettext as _ %>
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%namespace name='static' file='../static_content.html'/>
<section class="outside-app">
<h1>
${_("There has been an error on the {platform_name} servers").format(
platform_name=u"<em>{}</em>".format(settings.PLATFORM_NAME)
platform_name=u"<em>{}</em>".format(static.get_platform_name())
)}
</h1>
<p>
${_("We're sorry, this module is temporarily unavailable. Our staff is working "
"to fix it as soon as possible. Please email us at {tech_support_email} to "
"report any problems or downtime.").format(
tech_support_email=u"<a href=\"mailto:{0}\">{0}</a>".format(settings.TECH_SUPPORT_EMAIL)
tech_support_email=u"<a href=\"mailto:{0}\">{0}</a>".format(static.get_tech_support_email_address())
)}
</p>
......
......@@ -4,18 +4,19 @@ from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%inherit file="../main.html" />
<%namespace name='static' file='../static_content.html'/>
<main id="main" aria-label="Content" tabindex="-1">
<section class="outside-app">
<h1>
${Text(_("Currently the {platform_name} servers are down")).format(
platform_name=HTML(u"<em>{}</em>").format(Text(settings.PLATFORM_NAME))
platform_name=HTML(u"<em>{}</em>").format(Text(static.get_platform_name()))
)}
</h1>
<p>
${Text(_("Our staff is currently working to get the site back up as soon as possible. "
"Please email us at {tech_support_email} to report any problems or downtime.")).format(
tech_support_email=HTML('<a href="mailto:{0}">{0}</a>').format(Text(settings.TECH_SUPPORT_EMAIL))
tech_support_email=HTML('<a href="mailto:{0}">{0}</a>').format(Text(static.get_tech_support_email_address()))
)}</p>
</section>
</main>
......@@ -4,18 +4,19 @@ from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%inherit file="../main.html" />
<%namespace name='static' file='../static_content.html'/>
<main id="main" aria-label="Content" tabindex="-1">
<section class="outside-app">
<h1>
${Text(_("Currently the {platform_name} servers are overloaded")).format(
platform_name=HTML("<em>{}</em>").format(platform_name=Text(settings.PLATFORM_NAME))
platform_name=HTML("<em>{}</em>").format(platform_name=Text(static.get_platform_name()))
)}
</h1>
<p>
${Text(_("Our staff is currently working to get the site back up as soon as possible. "
"Please email us at {tech_support_email} to report any problems or downtime.")).format(
tech_support_email=HTML('<a href="mailto:{0}">{0}</a>').format(tech_support_email=Text(settings.TECH_SUPPORT_EMAIL))
tech_support_email=HTML('<a href="mailto:{0}">{0}</a>').format(tech_support_email=Text(static.get_tech_support_email_address()))
)}
</p>
</section>
......
......@@ -104,6 +104,9 @@ urlpatterns = (
# URLs for managing dark launches of languages
url(r'^update_lang/', include('openedx.core.djangoapps.dark_lang.urls', namespace='dark_lang')),
# URLs for managing theming
url(r'^theming/', include('openedx.core.djangoapps.theming.urls', namespace='theming')),
# For redirecting to help pages.
url(r'^help_token/', include('help_tokens.urls')),
......
......@@ -268,7 +268,7 @@ class BookmarksListViewTests(BookmarksViewsTestsBase):
self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.')
# Send empty data dictionary.
with self.assertNumQueries(7): # No queries for bookmark table.
with self.assertNumQueries(8): # No queries for bookmark table.
response = self.send_post(
client=self.client,
url=reverse('bookmarks'),
......
......@@ -93,6 +93,7 @@ class BlockStructureStore(object):
serialized_data = self._get_from_cache(bs_model)
except BlockStructureNotFound:
serialized_data = self._get_from_store(bs_model)
self._add_to_cache(serialized_data, bs_model)
return self._deserialize(serialized_data, root_block_usage_key)
......
......@@ -36,13 +36,13 @@ def show_reference_template(request, template):
# Support dynamic rendering of messages
if request.GET.get('alert'):
register_info_message(request, request.GET.get('alert'))
PageLevelMessages.register_info_message(request, request.GET.get('alert'))
if request.GET.get('success'):
register_success_message(request, request.GET.get('success'))
PageLevelMessages.register_success_message(request, request.GET.get('success'))
if request.GET.get('warning'):
register_warning_message(request, request.GET.get('warning'))
PageLevelMessages.register_warning_message(request, request.GET.get('warning'))
if request.GET.get('error'):
register_error_message(request, request.GET.get('error'))
PageLevelMessages.register_error_message(request, request.GET.get('error'))
# Add some messages to the course skeleton pages
if u'course-skeleton.html' in request.path:
......
......@@ -36,6 +36,7 @@ import student.views
from edxmako.shortcuts import render_to_response, render_to_string
from openedx.core.djangoapps.external_auth.djangostore import DjangoOpenIDStore
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
from openedx.core.djangoapps.site_configuration.helpers import get_value
from student.helpers import get_next_url_for_login_page
from student.models import UserProfile
from xmodule.modulestore.django import modulestore
......@@ -171,7 +172,7 @@ def _external_login_or_signup(request,
"an external login like WebAuth or Shibboleth. "
"Please contact {tech_support_email} for support."
).format(
tech_support_email=settings.TECH_SUPPORT_EMAIL,
tech_support_email=get_value('email_from_address', settings.TECH_SUPPORT_EMAIL),
)
return default_render_failure(request, failure_msg)
except User.DoesNotExist:
......
......@@ -8,6 +8,7 @@ from django.conf import settings
from django.contrib.staticfiles.storage import staticfiles_storage
from django.http import HttpResponse
from django.shortcuts import render_to_response
from edxmako.shortcuts import is_any_marketing_link_set, is_marketing_link_set, marketing_link
from web_fragments.views import FragmentView
log = logging.getLogger('plugin_api')
......@@ -17,8 +18,6 @@ class EdxFragmentView(FragmentView):
"""
The base class of all Open edX fragment views.
"""
USES_PATTERN_LIBRARY = True
page_title = None
@staticmethod
......@@ -78,6 +77,44 @@ class EdxFragmentView(FragmentView):
for js_file in self.js_dependencies():
fragment.add_javascript_url(staticfiles_storage.url(js_file))
def create_base_standalone_context(self, request, fragment, **kwargs):
"""
Creates the base context for rendering a fragment as a standalone page.
"""
return {
'uses_pattern_library': True,
'disable_accordion': True,
'allow_iframing': True,
'disable_header': True,
'disable_footer': True,
'disable_window_wrap': True,
}
def _add_studio_standalone_context_variables(self, request, context):
"""
Adds Studio-specific context variables for fragment standalone pages.
Note: this is meant to be a temporary hack to ensure that Studio
receives the context variables that are expected by some of its
shared templates. Ideally these templates shouldn't depend upon
this data being provided but should instead import the functionality
it needs.
"""
context.update({
'request': request,
'settings': settings,
'EDX_ROOT_URL': settings.EDX_ROOT_URL,
'marketing_link': marketing_link,
'is_any_marketing_link_set': is_any_marketing_link_set,
'is_marketing_link_set': is_marketing_link_set,
})
def standalone_page_title(self, request, fragment, **kwargs):
"""
Returns the page title for the standalone page, or None if there is no title.
"""
return None
def render_standalone_response(self, request, fragment, **kwargs):
"""
Renders a standalone page for the specified fragment.
......@@ -86,14 +123,18 @@ class EdxFragmentView(FragmentView):
"""
if fragment is None:
return HttpResponse(status=204)
context = {
'uses-pattern-library': self.USES_PATTERN_LIBRARY,
context = self.create_base_standalone_context(request, fragment, **kwargs)
self._add_studio_standalone_context_variables(request, context)
context.update({
'settings': settings,
'fragment': fragment,
'disable_accordion': True,
'allow_iframing': True,
'disable_header': True,
'disable_footer': True,
'disable_window_wrap': True,
}
return render_to_response(settings.STANDALONE_FRAGMENT_VIEW_TEMPLATE, context)
'page_title': self.standalone_page_title(request, fragment, **kwargs),
})
if context.get('uses_pattern_library', False):
template = 'fragments/standalone-page-v2.html'
elif context.get('uses_bootstrap', False):
template = 'fragments/standalone-page-bootstrap.html'
else:
template = 'fragments/standalone-page-v1.html'
return render_to_response(template, context)
......@@ -366,6 +366,16 @@ def get_themes(themes_dir=None):
return themes
def theme_exists(theme_name, themes_dir=None):
"""
Returns True if a theme exists with the specified name.
"""
for theme in get_themes(themes_dir=themes_dir):
if theme.theme_dir_name == theme_name:
return True
return False
def get_theme_dirs(themes_dir=None):
"""
Returns theme dirs in given dirs
......
......@@ -7,19 +7,25 @@ Note:
"""
from django.conf import settings
from openedx.core.djangoapps.theming.models import SiteTheme
from .models import SiteTheme
from .views import get_user_preview_site_theme
class CurrentSiteThemeMiddleware(object):
"""
Middleware that sets `site_theme` attribute to request object.
"""
def process_request(self, request):
"""
fetch Site Theme for the current site and add it to the request object.
Set the request's 'site_theme' attribute based upon the current user.
"""
default_theme = None
if settings.DEFAULT_SITE_THEME:
default_theme = SiteTheme(site=request.site, theme_dir_name=settings.DEFAULT_SITE_THEME)
request.site_theme = SiteTheme.get_theme(request.site, default=default_theme)
# Determine if the user has specified a preview site
preview_site_theme = get_user_preview_site_theme(request)
if preview_site_theme:
site_theme = preview_site_theme
else:
default_theme = None
if settings.DEFAULT_SITE_THEME:
default_theme = SiteTheme(site=request.site, theme_dir_name=settings.DEFAULT_SITE_THEME)
site_theme = SiteTheme.get_theme(request.site, default=default_theme)
request.site_theme = site_theme
"""
Django models supporting the Comprehensive Theming subsystem
"""
from django.conf import settings
from django.contrib.sites.models import Site
from django.db import models
......
## mako
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangoapps.theming.helpers import get_themes
%>
<h3>
${_("Theming Administration")}
</h3>
<div>
<form class="form" action="${request.path}" method="post">
<div class="form-group">
<label>${_("Preview Theme")}
<select class="form-control" name="preview_theme">
<%
all_themes = list(get_themes())
all_themes.sort(key=lambda x: x.theme_dir_name)
current_theme_name = request.site_theme.theme_dir_name if request.site_theme else None
%>
% for theme in all_themes:
<% theme_name = theme.theme_dir_name %>
<option value="${theme_name}"${' selected=selected' if theme_name == current_theme_name else ''}>${theme_name}</option>
% endfor
</select>
</label>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit" name="action" value="set_preview_theme">${_("Submit")}</button>
<button class="btn btn-secondary" type="submit" name="action" value="reset_preview_theme">${_("Reset")}</button>
</div>
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }"/>
</form>
<p>See also <a href="/admin">Django admin</a> for more theming settings.</p>
</div>
"""
Tests for middleware for comprehensive themes.
"""
from mock import Mock
from django.test import TestCase, override_settings
from django.contrib.sites.models import Site
from django.contrib.messages.middleware import MessageMiddleware
from django.test import RequestFactory, TestCase, override_settings
from django.contrib.sites.models import Site
from openedx.core.djangoapps.theming.middleware import CurrentSiteThemeMiddleware
from student.tests.factories import UserFactory
from ..views import set_user_preview_site_theme
TEST_URL = '/test'
TEST_THEME_NAME = 'test-theme'
class TestCurrentSiteThemeMiddlewareLMS(TestCase):
class TestCurrentSiteThemeMiddleware(TestCase):
"""
Test theming middleware.
"""
......@@ -16,22 +22,38 @@ class TestCurrentSiteThemeMiddlewareLMS(TestCase):
"""
Initialize middleware and related objects
"""
super(TestCurrentSiteThemeMiddlewareLMS, self).setUp()
super(TestCurrentSiteThemeMiddleware, self).setUp()
self.site_theme_middleware = CurrentSiteThemeMiddleware()
self.request = Mock()
self.request.site, __ = Site.objects.get_or_create(domain="test", name="test")
self.request.session = {}
self.user = UserFactory.create()
def create_mock_get_request(self):
"""
Returns a mock GET request.
"""
request = RequestFactory().get(TEST_URL)
self.initialize_mock_request(request)
return request
def initialize_mock_request(self, request):
"""
Initialize a test request.
"""
request.user = self.user
request.site, __ = Site.objects.get_or_create(domain='test', name='test')
request.session = {}
MessageMiddleware().process_request(request)
@override_settings(DEFAULT_SITE_THEME="test-theme")
@override_settings(DEFAULT_SITE_THEME=TEST_THEME_NAME)
def test_default_site_theme(self):
"""
Test that request.site_theme returns theme defined by DEFAULT_SITE_THEME setting
when there is no theme associated with the current site.
"""
self.assertEqual(self.site_theme_middleware.process_request(self.request), None)
self.assertIsNotNone(self.request.site_theme)
self.assertEqual(self.request.site_theme.theme_dir_name, "test-theme")
request = self.create_mock_get_request()
self.assertEqual(self.site_theme_middleware.process_request(request), None)
self.assertIsNotNone(request.site_theme)
self.assertEqual(request.site_theme.theme_dir_name, TEST_THEME_NAME)
@override_settings(DEFAULT_SITE_THEME=None)
def test_default_site_theme_2(self):
......@@ -39,5 +61,30 @@ class TestCurrentSiteThemeMiddlewareLMS(TestCase):
Test that request.site_theme returns None when there is no theme associated with
the current site and DEFAULT_SITE_THEME is also None.
"""
self.assertEqual(self.site_theme_middleware.process_request(self.request), None)
self.assertIsNone(self.request.site_theme)
request = self.create_mock_get_request()
self.assertEqual(self.site_theme_middleware.process_request(request), None)
self.assertIsNone(request.site_theme)
def test_preview_theme(self):
"""
Verify that preview themes behaves correctly.
"""
# First request a preview theme
post_request = RequestFactory().post('/test')
self.initialize_mock_request(post_request)
set_user_preview_site_theme(post_request, TEST_THEME_NAME)
# Next request a page and verify that the theme is returned
get_request = self.create_mock_get_request()
self.assertEqual(self.site_theme_middleware.process_request(get_request), None)
self.assertEqual(get_request.site_theme.theme_dir_name, TEST_THEME_NAME)
# Request to reset the theme
post_request = RequestFactory().post('/test')
self.initialize_mock_request(post_request)
set_user_preview_site_theme(post_request, None)
# Finally verify that no theme is returned
get_request = self.create_mock_get_request()
self.assertEqual(self.site_theme_middleware.process_request(get_request), None)
self.assertIsNone(get_request.site_theme)
"""
Tests for comprehensive them
"""
from courseware.tests.factories import GlobalStaffFactory
from django.conf import settings
from django.contrib.messages.middleware import MessageMiddleware
from django.test import TestCase, override_settings
from django.contrib.sites.models import Site
from openedx.core.djangoapps.theming.middleware import CurrentSiteThemeMiddleware
from student.tests.factories import UserFactory
THEMING_ADMIN_URL = '/theming/admin'
TEST_THEME_NAME = 'test-theme'
TEST_PASSWORD = 'test'
class TestThemingViews(TestCase):
"""
Test theming views.
"""
def setUp(self):
"""
Initialize middleware and related objects
"""
super(TestThemingViews, self).setUp()
self.site_theme_middleware = CurrentSiteThemeMiddleware()
self.user = UserFactory.create()
def initialize_mock_request(self, request):
"""
Initialize a test request.
"""
request.user = self.user
request.site, __ = Site.objects.get_or_create(domain='test', name='test')
request.session = {}
MessageMiddleware().process_request(request)
def test_preview_theme_access(self):
"""
Verify that users have the correct access to preview themes.
"""
# Anonymous users get redirected to the login page
response = self.client.get(THEMING_ADMIN_URL)
self.assertRedirects(
response,
'{login_url}?next={url}'.format(
login_url=settings.LOGIN_REDIRECT_URL,
url=THEMING_ADMIN_URL,
)
)
# Logged in non-global staff get a 404
self.client.login(username=self.user.username, password=TEST_PASSWORD)
response = self.client.get(THEMING_ADMIN_URL)
self.assertEqual(response.status_code, 404)
# Global staff can access the page
global_staff = GlobalStaffFactory()
self.client.login(username=global_staff.username, password=TEST_PASSWORD)
response = self.client.get(THEMING_ADMIN_URL)
self.assertEqual(response.status_code, 200)
def test_preview_theme(self):
"""
Verify that preview themes behaves correctly.
"""
global_staff = GlobalStaffFactory()
self.client.login(username=global_staff.username, password=TEST_PASSWORD)
# First request a preview theme
post_response = self.client.post(
THEMING_ADMIN_URL,
{
'action': 'set_preview_theme',
'preview_theme': TEST_THEME_NAME,
}
)
self.assertRedirects(post_response, THEMING_ADMIN_URL)
# Next request a page and verify that the correct theme has been chosen
response = self.client.get(THEMING_ADMIN_URL)
self.assertEquals(response.status_code, 200)
self.assertContains(
response,
'<option value="{theme_name}" selected=selected>'.format(theme_name=TEST_THEME_NAME)
)
# Request to reset the theme
post_response = self.client.post(
THEMING_ADMIN_URL,
{
'action': 'reset_preview_theme'
}
)
self.assertRedirects(post_response, THEMING_ADMIN_URL)
# Finally verify that the test theme is no longer selected
response = self.client.get(THEMING_ADMIN_URL)
self.assertEquals(response.status_code, 200)
self.assertContains(
response,
'<option value="{theme_name}">'.format(theme_name=TEST_THEME_NAME)
)
"""
Defines URLs for theming views.
"""
from django.conf.urls import url
from .helpers import is_comprehensive_theming_enabled
from .views import ThemingAdministrationFragmentView
if is_comprehensive_theming_enabled():
urlpatterns = [
url(
r'^admin',
ThemingAdministrationFragmentView.as_view(),
name='openedx.theming.update_theme_fragment_view',
),
]
"""
Views file for theming administration.
"""
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.core.djangoapps.user_api.preferences.api import (
delete_user_preference,
get_user_preference,
set_user_preference,
)
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from student.roles import GlobalStaff
from web_fragments.fragment import Fragment
from .helpers import theme_exists
from .models import SiteTheme
PREVIEW_SITE_THEME_PREFERENCE_KEY = 'preview-site-theme'
PREVIEW_THEME_FIELD = 'preview_theme'
def user_can_preview_themes(user):
"""
Returns true if the specified user is allowed to preview themes.
"""
if not user or user.is_anonymous():
return False
# In development mode, all users can preview themes
if settings.DEBUG:
return True
# Otherwise, only global staff can preview themes
return GlobalStaff().has_user(user)
def get_user_preview_site_theme(request):
"""
Returns the preview site for the current user, or None if not set.
"""
user = request.user
if not user or user.is_anonymous():
return None
preview_site_name = get_user_preference(user, PREVIEW_SITE_THEME_PREFERENCE_KEY)
if not preview_site_name:
return None
return SiteTheme(site=request.site, theme_dir_name=preview_site_name)
def set_user_preview_site_theme(request, preview_site_theme):
"""
Sets the current user's preferred preview site theme.
Args:
request: the current request
preview_site_theme (str or SiteTheme): the preview site theme or theme name.
None can be specified to remove the preview site theme.
"""
if preview_site_theme:
if isinstance(preview_site_theme, SiteTheme):
preview_site_theme_name = preview_site_theme.theme_dir_name
else:
preview_site_theme_name = preview_site_theme
if theme_exists(preview_site_theme_name):
set_user_preference(request.user, PREVIEW_SITE_THEME_PREFERENCE_KEY, preview_site_theme_name)
PageLevelMessages.register_success_message(
request,
_('Site theme changed to {site_theme}'.format(site_theme=preview_site_theme_name))
)
else:
PageLevelMessages.register_error_message(
request,
_('Theme {site_theme} does not exist'.format(site_theme=preview_site_theme_name))
)
else:
delete_user_preference(request.user, PREVIEW_SITE_THEME_PREFERENCE_KEY)
PageLevelMessages.register_success_message(request, _('Site theme reverted to the default'))
class ThemingAdministrationFragmentView(EdxFragmentView):
"""
Fragment view to allow a user to administer theming.
"""
def render_to_fragment(self, request, course_id=None, **kwargs):
"""
Renders the theming administration view as a fragment.
"""
html = render_to_string('theming/theming-admin-fragment.html', {})
return Fragment(html)
@method_decorator(login_required)
def get(self, request, *args, **kwargs):
"""
Renders the theming admin fragment to authorized users.
"""
if not user_can_preview_themes(request.user):
raise Http404
return super(ThemingAdministrationFragmentView, self).get(request, *args, **kwargs)
@method_decorator(login_required)
def post(self, request, **kwargs):
"""
Accept requests to update the theme preview.
"""
if not user_can_preview_themes(request.user):
raise Http404
action = request.POST.get('action', None)
if action == 'set_preview_theme':
preview_theme_name = request.POST.get(PREVIEW_THEME_FIELD, '')
set_user_preview_site_theme(request, preview_theme_name)
elif action == 'reset_preview_theme':
set_user_preview_site_theme(request, None)
return redirect(request.path)
def create_base_standalone_context(self, request, fragment, **kwargs):
"""
Creates the context to use when rendering a standalone page.
"""
return {
'uses_bootstrap': True,
}
def standalone_page_title(self, request, fragment, **kwargs):
"""
Returns the page title for the standalone update page.
"""
return _('Theming Administration')
......@@ -174,7 +174,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
Test that a client (logged in) can get her own username.
"""
self.client.login(username=self.user.username, password=TEST_PASSWORD)
self._verify_get_own_username(14)
self._verify_get_own_username(15)
def test_get_username_inactive(self):
"""
......@@ -184,7 +184,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.user.is_active = False
self.user.save()
self._verify_get_own_username(14)
self._verify_get_own_username(15)
def test_get_username_not_logged_in(self):
"""
......@@ -305,7 +305,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
with self.assertNumQueries(18):
with self.assertNumQueries(19):
response = self.send_get(self.different_client)
self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY)
......@@ -320,7 +320,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
with self.assertNumQueries(18):
with self.assertNumQueries(19):
response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
......@@ -395,12 +395,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
self.assertEqual(False, data["accomplishments_shared"])
self.client.login(username=self.user.username, password=TEST_PASSWORD)
verify_get_own_information(16)
verify_get_own_information(17)
# Now make sure that the user can get the same information, even if not active
self.user.is_active = False
self.user.save()
verify_get_own_information(10)
verify_get_own_information(11)
def test_get_account_empty_string(self):
"""
......@@ -414,7 +414,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
legacy_profile.save()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
with self.assertNumQueries(16):
with self.assertNumQueries(17):
response = self.send_get(self.client)
for empty_field in ("level_of_education", "gender", "country", "bio"):
self.assertIsNone(response.data[empty_field])
......
......@@ -774,6 +774,142 @@ class PasswordResetViewTest(UserAPITestCase):
@ddt.ddt
@skip_unless_lms
class RegistrationViewValidationErrorTest(ThirdPartyAuthTestMixin, UserAPITestCase):
"""
Tests for catching duplicate email and username validation errors within
the registration end-points of the User API.
"""
maxDiff = None
USERNAME = "bob"
EMAIL = "bob@example.com"
PASSWORD = "password"
NAME = "Bob Smith"
EDUCATION = "m"
YEAR_OF_BIRTH = "1998"
ADDRESS = "123 Fake Street"
CITY = "Springfield"
COUNTRY = "us"
GOALS = "Learn all the things!"
def setUp(self):
super(RegistrationViewValidationErrorTest, self).setUp()
self.url = reverse("user_api_registration")
@mock.patch('openedx.core.djangoapps.user_api.views.check_account_exists')
def test_register_duplicate_email_validation_error(self, dummy_check_account_exists):
dummy_check_account_exists.return_value = []
# Register the first user
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertHttpOK(response)
# Try to create a second user with the same email address
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": "Someone Else",
"username": "someone_else",
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertEqual(response.status_code, 400)
response_json = json.loads(response.content)
self.assertEqual(
response_json,
{
"email": [{
"user_message": (
"It looks like {} belongs to an existing account. "
"Try again with a different email address."
).format(
self.EMAIL
)
}]
}
)
@mock.patch('openedx.core.djangoapps.user_api.views.check_account_exists')
def test_register_duplicate_username_account_validation_error(self, dummy_check_account_exists):
dummy_check_account_exists.return_value = []
# Register the first user
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertHttpOK(response)
# Try to create a second user with the same username
response = self.client.post(self.url, {
"email": "someone+else@example.com",
"name": "Someone Else",
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertEqual(response.status_code, 409)
response_json = json.loads(response.content)
self.assertEqual(
response_json,
{
u"username": [{
u"user_message": (
u"An account with the Public Username '{}' already exists."
).format(
self.USERNAME
)
}]
}
)
@mock.patch('openedx.core.djangoapps.user_api.views.check_account_exists')
def test_register_duplicate_username_and_email_validation_errors(self, dummy_check_account_exists):
dummy_check_account_exists.return_value = []
# Register the first user
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertHttpOK(response)
# Try to create a second user with the same username
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": "Someone Else",
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertEqual(response.status_code, 400)
response_json = json.loads(response.content)
self.assertEqual(
response_json,
{
"email": [{
"user_message": (
"It looks like {} belongs to an existing account. "
"Try again with a different email address."
).format(
self.EMAIL
)
}]
}
)
@ddt.ddt
@skip_unless_lms
class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
"""Tests for the registration end-points of the User API. """
......
......@@ -28,7 +28,7 @@ from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
from openedx.features.enterprise_support.api import enterprise_customer_for_request
from student.cookies import set_logged_in_cookies
from student.forms import get_registration_extension_form
from student.views import create_account_with_params
from student.views import create_account_with_params, AccountValidationError
from util.json_request import JsonResponse
from .accounts import (
......@@ -371,6 +371,11 @@ class RegistrationView(APIView):
try:
user = create_account_with_params(request, data)
except AccountValidationError as err:
errors = {
err.field: [{"user_message": err.message}]
}
return JsonResponse(errors, status=409)
except ValidationError as err:
# Should only get non-field errors from this function
assert NON_FIELD_ERRORS not in err.message_dict
......
......@@ -5,10 +5,18 @@ Django admin page for verified track configuration
from django.contrib import admin
from openedx.core.djangoapps.verified_track_content.forms import VerifiedTrackCourseForm
from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse
from openedx.core.djangoapps.verified_track_content.models import (
MigrateVerifiedTrackCohortsSetting,
VerifiedTrackCohortedCourse
)
@admin.register(VerifiedTrackCohortedCourse)
class VerifiedTrackCohortedCourseAdmin(admin.ModelAdmin):
"""Admin for enabling verified track cohorting. """
form = VerifiedTrackCourseForm
@admin.register(MigrateVerifiedTrackCohortsSetting)
class MigrateVerifiedTrackCohortsSettingAdmin(admin.ModelAdmin):
"""Admin for configuring migration settings of verified track cohorting"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import openedx.core.djangoapps.xmodule_django.models
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('verified_track_content', '0002_verifiedtrackcohortedcourse_verified_cohort_name'),
]
operations = [
migrations.CreateModel(
name='MigrateVerifiedTrackCohortsSetting',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('old_course_key', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(help_text=b'Course key for which to migrate verified track cohorts from', max_length=255)),
('rerun_course_key', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(help_text=b'Course key for which to migrate verified track cohorts to enrollment tracks to', max_length=255)),
('audit_cohort_names', models.TextField(help_text=b'Comma-separated list of audit cohort names')),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
),
]
......@@ -3,6 +3,7 @@ Models for verified track selections.
"""
import logging
from config_models.models import ConfigurationModel
from django.db import models
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
......@@ -147,3 +148,30 @@ class VerifiedTrackCohortedCourse(models.Model):
def invalidate_verified_track_cache(sender, **kwargs): # pylint: disable=unused-argument
"""Invalidate the cache of VerifiedTrackCohortedCourse. """
RequestCache.clear_request_cache(name=VerifiedTrackCohortedCourse.CACHE_NAMESPACE)
class MigrateVerifiedTrackCohortsSetting(ConfigurationModel):
"""
Configuration for the swap_from_auto_track_cohorts management command.
"""
class Meta(object):
app_label = "verified_track_content"
old_course_key = CourseKeyField(
max_length=255,
blank=False,
help_text="Course key for which to migrate verified track cohorts from"
)
rerun_course_key = CourseKeyField(
max_length=255,
blank=False,
help_text="Course key for which to migrate verified track cohorts to enrollment tracks to"
)
audit_cohort_names = models.TextField(
help_text="Comma-separated list of audit cohort names"
)
@classmethod
def get_audit_cohort_names(cls):
"""Get the list of audit cohort names for the course"""
return [cohort_name for cohort_name in cls.current().audit_cohort_names.split(",") if cohort_name]
......@@ -8,6 +8,7 @@ from django.core.urlresolvers import reverse
from django.shortcuts import render_to_response
from django.template.loader import render_to_string
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View
......@@ -80,3 +81,9 @@ class CourseBookmarksFragmentView(EdxFragmentView):
self.add_fragment_resource_urls(fragment)
fragment.add_javascript(inline_js)
return fragment
def standalone_page_title(self, request, fragment, **kwargs):
"""
Returns the standalone page title.
"""
return _('Bookmarks')
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment