Merge branch 'master' into malikshahzad228/additional_course_fields
This commit is contained in:
@@ -23,6 +23,8 @@ from mock import Mock
|
||||
from opaque_keys.edx.locator import CourseKey, LibraryLocator
|
||||
from openedx.core.djangoapps.content.course_structures.tests import SignalDisconnectTestMixin
|
||||
from xblock_django.user_service import DjangoXBlockUserService
|
||||
from xmodule.x_module import STUDIO_VIEW
|
||||
from student import auth
|
||||
|
||||
|
||||
class LibraryTestCase(ModuleStoreTestCase):
|
||||
@@ -30,16 +32,22 @@ class LibraryTestCase(ModuleStoreTestCase):
|
||||
Common functionality for content libraries tests
|
||||
"""
|
||||
def setUp(self):
|
||||
user_password = super(LibraryTestCase, self).setUp()
|
||||
self.user_password = super(LibraryTestCase, self).setUp()
|
||||
|
||||
self.client = AjaxEnabledTestClient()
|
||||
self.client.login(username=self.user.username, password=user_password)
|
||||
self._login_as_staff_user(logout_first=False)
|
||||
|
||||
self.lib_key = self._create_library()
|
||||
self.library = modulestore().get_library(self.lib_key)
|
||||
|
||||
self.session_data = {} # Used by _bind_module
|
||||
|
||||
def _login_as_staff_user(self, logout_first=True):
|
||||
""" Login as a staff user """
|
||||
if logout_first:
|
||||
self.client.logout()
|
||||
self.client.login(username=self.user.username, password=self.user_password)
|
||||
|
||||
def _create_library(self, org="org", library="lib", display_name="Test Library"):
|
||||
"""
|
||||
Helper method used to create a library. Uses the REST API.
|
||||
@@ -729,6 +737,64 @@ class TestLibraryAccess(SignalDisconnectTestMixin, LibraryTestCase):
|
||||
lc_block = self._refresh_children(lc_block, status_code_expected=200 if expected_result else 403)
|
||||
self.assertEqual(len(lc_block.children), 1 if expected_result else 0)
|
||||
|
||||
def test_studio_user_permissions(self):
|
||||
"""
|
||||
Test that user could attach to the problem only libraries that he has access (or which were created by him).
|
||||
This test was created on the basis of bug described in the pull requests on github:
|
||||
https://github.com/edx/edx-platform/pull/11331
|
||||
https://github.com/edx/edx-platform/pull/11611
|
||||
"""
|
||||
self._create_library(org='admin_org_1', library='lib_adm_1', display_name='admin_lib_1')
|
||||
self._create_library(org='admin_org_2', library='lib_adm_2', display_name='admin_lib_2')
|
||||
|
||||
self._login_as_non_staff_user()
|
||||
|
||||
self._create_library(org='staff_org_1', library='lib_staff_1', display_name='staff_lib_1')
|
||||
self._create_library(org='staff_org_2', library='lib_staff_2', display_name='staff_lib_2')
|
||||
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
instructor_role = CourseInstructorRole(course.id)
|
||||
auth.add_users(self.user, instructor_role, self.non_staff_user)
|
||||
|
||||
lib_block = ItemFactory.create(
|
||||
category='library_content',
|
||||
parent_location=course.location,
|
||||
user_id=self.non_staff_user.id,
|
||||
publish_item=False
|
||||
)
|
||||
|
||||
def _get_settings_html():
|
||||
"""
|
||||
Helper function to get block settings HTML
|
||||
Used to check the available libraries.
|
||||
"""
|
||||
edit_view_url = reverse_usage_url("xblock_view_handler", lib_block.location, {"view_name": STUDIO_VIEW})
|
||||
|
||||
resp = self.client.get_json(edit_view_url)
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
|
||||
return parse_json(resp)['html']
|
||||
|
||||
self._login_as_staff_user()
|
||||
staff_settings_html = _get_settings_html()
|
||||
self.assertIn('staff_lib_1', staff_settings_html)
|
||||
self.assertIn('staff_lib_2', staff_settings_html)
|
||||
self.assertIn('admin_lib_1', staff_settings_html)
|
||||
self.assertIn('admin_lib_2', staff_settings_html)
|
||||
|
||||
self._login_as_non_staff_user()
|
||||
response = self.client.get_json(LIBRARY_REST_URL)
|
||||
staff_libs = parse_json(response)
|
||||
self.assertEquals(2, len(staff_libs))
|
||||
|
||||
non_staff_settings_html = _get_settings_html()
|
||||
self.assertIn('staff_lib_1', non_staff_settings_html)
|
||||
self.assertIn('staff_lib_2', non_staff_settings_html)
|
||||
self.assertNotIn('admin_lib_1', non_staff_settings_html)
|
||||
self.assertNotIn('admin_lib_2', non_staff_settings_html)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@override_settings(SEARCH_ENGINE=None)
|
||||
|
||||
@@ -21,7 +21,7 @@ from xblock.runtime import Mixologist
|
||||
|
||||
from contentstore.utils import get_lms_link_for_item
|
||||
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
|
||||
from contentstore.views.item import create_xblock_info, add_container_page_publishing_info
|
||||
from contentstore.views.item import create_xblock_info, add_container_page_publishing_info, StudioEditModuleRuntime
|
||||
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
|
||||
@@ -330,6 +330,7 @@ def component_handler(request, usage_key_string, handler, suffix=''):
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
|
||||
descriptor = modulestore().get_item(usage_key)
|
||||
descriptor.xmodule_runtime = StudioEditModuleRuntime(request.user)
|
||||
# Let the module handle the AJAX
|
||||
req = django_to_webob_request(request)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from opaque_keys.edx.locator import LibraryUsageLocator
|
||||
from pytz import UTC
|
||||
from xblock.fields import Scope
|
||||
from xblock.fragment import Fragment
|
||||
from xblock_django.user_service import DjangoXBlockUserService
|
||||
|
||||
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
|
||||
from contentstore.utils import (
|
||||
@@ -51,6 +52,7 @@ from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.tabs import CourseTabList
|
||||
from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW, DEPRECATION_VSCOMPAT_EVENT
|
||||
|
||||
|
||||
__all__ = [
|
||||
'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler'
|
||||
]
|
||||
@@ -198,6 +200,49 @@ def xblock_handler(request, usage_key_string):
|
||||
)
|
||||
|
||||
|
||||
class StudioPermissionsService(object):
|
||||
"""
|
||||
Service that can provide information about a user's permissions.
|
||||
|
||||
Deprecated. To be replaced by a more general authorization service.
|
||||
|
||||
Only used by LibraryContentDescriptor (and library_tools.py).
|
||||
"""
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
|
||||
def can_read(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_read_access(self._user, course_key)
|
||||
|
||||
def can_write(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_write_access(self._user, course_key)
|
||||
|
||||
|
||||
class StudioEditModuleRuntime(object):
|
||||
"""
|
||||
An extremely minimal ModuleSystem shim used for XBlock edits and studio_view.
|
||||
(i.e. whenever we're not using PreviewModuleSystem.) This is required to make information
|
||||
about the current user (especially permissions) available via services as needed.
|
||||
"""
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
|
||||
def service(self, block, service_name):
|
||||
"""
|
||||
This block is not bound to a user but some blocks (LibraryContentModule) may need
|
||||
user-specific services to check for permissions, etc.
|
||||
If we return None here, CombinedSystem will load services from the descriptor runtime.
|
||||
"""
|
||||
if block.service_declaration(service_name) is not None:
|
||||
if service_name == "user":
|
||||
return DjangoXBlockUserService(self._user)
|
||||
if service_name == "studio_user_permissions":
|
||||
return StudioPermissionsService(self._user)
|
||||
return None
|
||||
|
||||
|
||||
@require_http_methods(("GET"))
|
||||
@login_required
|
||||
@expect_json
|
||||
@@ -231,6 +276,9 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
))
|
||||
|
||||
if view_name in (STUDIO_VIEW, VISIBILITY_VIEW):
|
||||
if view_name == STUDIO_VIEW and xblock.xmodule_runtime is None:
|
||||
xblock.xmodule_runtime = StudioEditModuleRuntime(request.user)
|
||||
|
||||
try:
|
||||
fragment = xblock.render(view_name)
|
||||
# catch exceptions indiscriminately, since after this point they escape the
|
||||
@@ -375,6 +423,7 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
|
||||
old_metadata = own_metadata(xblock)
|
||||
if old_content is None:
|
||||
old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
xblock.xmodule_runtime = StudioEditModuleRuntime(user)
|
||||
xblock.editor_saved(user, old_metadata, old_content)
|
||||
|
||||
# Update after the callback so any changes made in the callback will get persisted.
|
||||
@@ -624,6 +673,7 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
|
||||
# Allow an XBlock to do anything fancy it may need to when duplicated from another block.
|
||||
# These blocks may handle their own children or parenting if needed. Let them return booleans to
|
||||
# let us know if we need to handle these or not.
|
||||
dest_module.xmodule_runtime = StudioEditModuleRuntime(user)
|
||||
children_handled = dest_module.studio_post_duplicate(store, source_item)
|
||||
|
||||
# Children are not automatically copied over (and not all xblocks have a 'children' attribute).
|
||||
|
||||
@@ -16,7 +16,6 @@ from xmodule.x_module import PREVIEW_VIEWS, STUDENT_VIEW, AUTHOR_VIEW
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.services import SettingsService
|
||||
from xmodule.modulestore.django import modulestore, ModuleI18nService
|
||||
from xmodule.mixin import wrap_with_license
|
||||
@@ -150,28 +149,6 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
|
||||
return result
|
||||
|
||||
|
||||
class StudioPermissionsService(object):
|
||||
"""
|
||||
Service that can provide information about a user's permissions.
|
||||
|
||||
Deprecated. To be replaced by a more general authorization service.
|
||||
|
||||
Only used by LibraryContentDescriptor (and library_tools.py).
|
||||
"""
|
||||
|
||||
def __init__(self, request):
|
||||
super(StudioPermissionsService, self).__init__()
|
||||
self._request = request
|
||||
|
||||
def can_read(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_read_access(self._request.user, course_key)
|
||||
|
||||
def can_write(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_write_access(self._request.user, course_key)
|
||||
|
||||
|
||||
def _preview_module_system(request, descriptor, field_data):
|
||||
"""
|
||||
Returns a ModuleSystem for the specified descriptor that is specialized for
|
||||
@@ -213,8 +190,6 @@ def _preview_module_system(request, descriptor, field_data):
|
||||
# stick the license wrapper in front
|
||||
wrappers.insert(0, wrap_with_license)
|
||||
|
||||
descriptor.runtime._services['studio_user_permissions'] = StudioPermissionsService(request) # pylint: disable=protected-access
|
||||
|
||||
return PreviewModuleSystem(
|
||||
static_url=settings.STATIC_URL,
|
||||
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
|
||||
@@ -241,7 +216,6 @@ def _preview_module_system(request, descriptor, field_data):
|
||||
services={
|
||||
"field-data": field_data,
|
||||
"i18n": ModuleI18nService,
|
||||
"library_tools": LibraryToolsService(modulestore()),
|
||||
"settings": SettingsService(),
|
||||
"user": DjangoXBlockUserService(request.user),
|
||||
},
|
||||
|
||||
@@ -576,12 +576,10 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe
|
||||
"""
|
||||
lib_tools = self.runtime.service(self, 'library_tools')
|
||||
user_perms = self.runtime.service(self, 'studio_user_permissions')
|
||||
all_libraries = lib_tools.list_available_libraries()
|
||||
if user_perms:
|
||||
all_libraries = [
|
||||
(key, name) for key, name in all_libraries
|
||||
if user_perms.can_read(key) or self.source_library_id == unicode(key)
|
||||
]
|
||||
all_libraries = [
|
||||
(key, name) for key, name in lib_tools.list_available_libraries()
|
||||
if user_perms.can_read(key) or self.source_library_id == unicode(key)
|
||||
]
|
||||
all_libraries.sort(key=lambda entry: entry[1]) # Sort by name
|
||||
if self.source_library_id and self.source_library_key not in [entry[0] for entry in all_libraries]:
|
||||
all_libraries.append((self.source_library_id, _(u"Invalid Library")))
|
||||
|
||||
@@ -1482,8 +1482,9 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime):
|
||||
"""
|
||||
potential_set = set(super(DescriptorSystem, self).applicable_aside_types(block))
|
||||
if getattr(block, 'xmodule_runtime', None) is not None:
|
||||
application_set = set(block.xmodule_runtime.applicable_aside_types(block))
|
||||
return list(potential_set.intersection(application_set))
|
||||
if hasattr(block.xmodule_runtime, 'applicable_aside_types'):
|
||||
application_set = set(block.xmodule_runtime.applicable_aside_types(block))
|
||||
return list(potential_set.intersection(application_set))
|
||||
return list(potential_set)
|
||||
|
||||
def resource_url(self, resource):
|
||||
|
||||
366
common/static/js/vendor/jasmine-jquery.js
vendored
366
common/static/js/vendor/jasmine-jquery.js
vendored
@@ -1,366 +0,0 @@
|
||||
var readFixtures = function() {
|
||||
return jasmine.getFixtures().proxyCallTo_('read', arguments)
|
||||
}
|
||||
|
||||
var preloadFixtures = function() {
|
||||
jasmine.getFixtures().proxyCallTo_('preload', arguments)
|
||||
}
|
||||
|
||||
var loadFixtures = function() {
|
||||
jasmine.getFixtures().proxyCallTo_('load', arguments)
|
||||
}
|
||||
|
||||
var appendLoadFixtures = function() {
|
||||
jasmine.getFixtures().proxyCallTo_('appendLoad', arguments)
|
||||
}
|
||||
|
||||
var setFixtures = function(html) {
|
||||
jasmine.getFixtures().proxyCallTo_('set', arguments)
|
||||
}
|
||||
|
||||
var appendSetFixtures = function() {
|
||||
jasmine.getFixtures().proxyCallTo_('appendSet', arguments)
|
||||
}
|
||||
|
||||
var sandbox = function(attributes) {
|
||||
return jasmine.getFixtures().sandbox(attributes)
|
||||
}
|
||||
|
||||
var spyOnEvent = function(selector, eventName) {
|
||||
jasmine.JQuery.events.spyOn(selector, eventName)
|
||||
}
|
||||
|
||||
jasmine.getFixtures = function() {
|
||||
return jasmine.currentFixtures_ = jasmine.currentFixtures_ || new jasmine.Fixtures()
|
||||
}
|
||||
|
||||
jasmine.Fixtures = function() {
|
||||
this.containerId = 'jasmine-fixtures'
|
||||
this.fixturesCache_ = {}
|
||||
this.fixturesPath = 'spec/javascripts/fixtures'
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.set = function(html) {
|
||||
this.cleanUp()
|
||||
this.createContainer_(html)
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.appendSet= function(html) {
|
||||
this.addToContainer_(html)
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.preload = function() {
|
||||
this.read.apply(this, arguments)
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.load = function() {
|
||||
this.cleanUp()
|
||||
this.createContainer_(this.read.apply(this, arguments))
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.appendLoad = function() {
|
||||
this.addToContainer_(this.read.apply(this, arguments))
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.read = function() {
|
||||
var htmlChunks = []
|
||||
|
||||
var fixtureUrls = arguments
|
||||
for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) {
|
||||
htmlChunks.push(this.getFixtureHtml_(fixtureUrls[urlIndex]))
|
||||
}
|
||||
|
||||
return htmlChunks.join('')
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.clearCache = function() {
|
||||
this.fixturesCache_ = {}
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.cleanUp = function() {
|
||||
jQuery('#' + this.containerId).remove()
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.sandbox = function(attributes) {
|
||||
var attributesToSet = attributes || {}
|
||||
return jQuery('<div id="sandbox" />').attr(attributesToSet)
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.createContainer_ = function(html) {
|
||||
var container
|
||||
if(html instanceof jQuery) {
|
||||
container = jQuery('<div id="' + this.containerId + '" />')
|
||||
container.html(html)
|
||||
} else {
|
||||
container = '<div id="' + this.containerId + '">' + html + '</div>'
|
||||
}
|
||||
jQuery('body').append(container)
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.addToContainer_ = function(html){
|
||||
var container = jQuery('body').find('#'+this.containerId).append(html)
|
||||
if(!container.length){
|
||||
this.createContainer_(html)
|
||||
}
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.getFixtureHtml_ = function(url) {
|
||||
if (typeof this.fixturesCache_[url] === 'undefined') {
|
||||
this.loadFixtureIntoCache_(url)
|
||||
}
|
||||
return this.fixturesCache_[url]
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.loadFixtureIntoCache_ = function(relativeUrl) {
|
||||
var url = this.makeFixtureUrl_(relativeUrl)
|
||||
var request = new XMLHttpRequest()
|
||||
request.open("GET", url + "?" + new Date().getTime(), false)
|
||||
request.send(null)
|
||||
this.fixturesCache_[relativeUrl] = request.responseText
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.makeFixtureUrl_ = function(relativeUrl){
|
||||
return this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl
|
||||
}
|
||||
|
||||
jasmine.Fixtures.prototype.proxyCallTo_ = function(methodName, passedArguments) {
|
||||
return this[methodName].apply(this, passedArguments)
|
||||
}
|
||||
|
||||
|
||||
jasmine.JQuery = function() {}
|
||||
|
||||
jasmine.JQuery.browserTagCaseIndependentHtml = function(html) {
|
||||
return jQuery('<div/>').append(html).html()
|
||||
}
|
||||
|
||||
jasmine.JQuery.elementToString = function(element) {
|
||||
var domEl = $(element).get(0)
|
||||
if (domEl == undefined || domEl.cloneNode)
|
||||
return jQuery('<div />').append($(element).clone()).html()
|
||||
else
|
||||
return element.toString()
|
||||
}
|
||||
|
||||
jasmine.JQuery.matchersClass = {};
|
||||
|
||||
!function(namespace) {
|
||||
var data = {
|
||||
spiedEvents: {},
|
||||
handlers: []
|
||||
}
|
||||
|
||||
namespace.events = {
|
||||
spyOn: function(selector, eventName) {
|
||||
var handler = function(e) {
|
||||
data.spiedEvents[[selector, eventName]] = e
|
||||
}
|
||||
jQuery(selector).bind(eventName, handler)
|
||||
data.handlers.push(handler)
|
||||
},
|
||||
|
||||
wasTriggered: function(selector, eventName) {
|
||||
return !!(data.spiedEvents[[selector, eventName]])
|
||||
},
|
||||
|
||||
wasPrevented: function(selector, eventName) {
|
||||
return data.spiedEvents[[selector, eventName]].isDefaultPrevented()
|
||||
},
|
||||
|
||||
cleanUp: function() {
|
||||
data.spiedEvents = {}
|
||||
data.handlers = []
|
||||
}
|
||||
}
|
||||
}(jasmine.JQuery)
|
||||
|
||||
!function(){
|
||||
var jQueryMatchers = {
|
||||
toHaveClass: function(className) {
|
||||
return this.actual.hasClass(className)
|
||||
},
|
||||
|
||||
toHaveCss: function(css){
|
||||
for (var prop in css){
|
||||
if (this.actual.css(prop) !== css[prop]) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
toBeVisible: function() {
|
||||
return this.actual.is(':visible')
|
||||
},
|
||||
|
||||
toBeHidden: function() {
|
||||
return this.actual.is(':hidden')
|
||||
},
|
||||
|
||||
toBeSelected: function() {
|
||||
return this.actual.is(':selected')
|
||||
},
|
||||
|
||||
toBeChecked: function() {
|
||||
return this.actual.is(':checked')
|
||||
},
|
||||
|
||||
toBeEmpty: function() {
|
||||
return this.actual.is(':empty')
|
||||
},
|
||||
|
||||
toExist: function() {
|
||||
return $(document).find(this.actual).length
|
||||
},
|
||||
|
||||
toHaveAttr: function(attributeName, expectedAttributeValue) {
|
||||
return hasProperty(this.actual.attr(attributeName), expectedAttributeValue)
|
||||
},
|
||||
|
||||
toHaveProp: function(propertyName, expectedPropertyValue) {
|
||||
return hasProperty(this.actual.prop(propertyName), expectedPropertyValue)
|
||||
},
|
||||
|
||||
toHaveId: function(id) {
|
||||
return this.actual.attr('id') == id
|
||||
},
|
||||
|
||||
toHaveHtml: function(html) {
|
||||
return this.actual.html() == jasmine.JQuery.browserTagCaseIndependentHtml(html)
|
||||
},
|
||||
|
||||
toContainHtml: function(html){
|
||||
var actualHtml = this.actual.html()
|
||||
var expectedHtml = jasmine.JQuery.browserTagCaseIndependentHtml(html)
|
||||
return (actualHtml.indexOf(expectedHtml) >= 0)
|
||||
},
|
||||
|
||||
toHaveText: function(text) {
|
||||
var trimmedText = $.trim(this.actual.text())
|
||||
if (text && jQuery.isFunction(text.test)) {
|
||||
return text.test(trimmedText)
|
||||
} else {
|
||||
return trimmedText == text
|
||||
}
|
||||
},
|
||||
|
||||
toHaveValue: function(value) {
|
||||
return this.actual.val() == value
|
||||
},
|
||||
|
||||
toHaveData: function(key, expectedValue) {
|
||||
return hasProperty(this.actual.data(key), expectedValue)
|
||||
},
|
||||
|
||||
toBe: function(selector) {
|
||||
return this.actual.is(selector)
|
||||
},
|
||||
|
||||
toContain: function(selector) {
|
||||
return this.actual.find(selector).length
|
||||
},
|
||||
|
||||
toBeDisabled: function(selector){
|
||||
return this.actual.is(':disabled')
|
||||
},
|
||||
|
||||
toBeFocused: function(selector) {
|
||||
return this.actual.is(':focus')
|
||||
},
|
||||
|
||||
toHandle: function(event) {
|
||||
|
||||
var events = this.actual.data('events')
|
||||
|
||||
if(!events || !event || typeof event !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
var namespaces = event.split(".")
|
||||
var eventType = namespaces.shift()
|
||||
var sortedNamespaces = namespaces.slice(0).sort()
|
||||
var namespaceRegExp = new RegExp("(^|\\.)" + sortedNamespaces.join("\\.(?:.*\\.)?") + "(\\.|$)")
|
||||
|
||||
if(events[eventType] && namespaces.length) {
|
||||
for(var i = 0; i < events[eventType].length; i++) {
|
||||
var namespace = events[eventType][i].namespace
|
||||
if(namespaceRegExp.test(namespace)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return events[eventType] && events[eventType].length > 0
|
||||
}
|
||||
},
|
||||
|
||||
// tests the existence of a specific event binding + handler
|
||||
toHandleWith: function(eventName, eventHandler) {
|
||||
var stack = this.actual.data("events")[eventName]
|
||||
for (var i = 0; i < stack.length; i++) {
|
||||
if (stack[i].handler == eventHandler) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var hasProperty = function(actualValue, expectedValue) {
|
||||
if (expectedValue === undefined) return actualValue !== undefined
|
||||
return actualValue == expectedValue
|
||||
}
|
||||
|
||||
var bindMatcher = function(methodName) {
|
||||
var builtInMatcher = jasmine.Matchers.prototype[methodName]
|
||||
|
||||
jasmine.JQuery.matchersClass[methodName] = function() {
|
||||
if (this.actual
|
||||
&& (this.actual instanceof jQuery
|
||||
|| jasmine.isDomNode(this.actual))) {
|
||||
this.actual = $(this.actual)
|
||||
var result = jQueryMatchers[methodName].apply(this, arguments)
|
||||
var element;
|
||||
if (this.actual.get && (element = this.actual.get()[0]) && !$.isWindow(element) && element.tagName !== "HTML")
|
||||
this.actual = jasmine.JQuery.elementToString(this.actual)
|
||||
return result
|
||||
}
|
||||
|
||||
if (builtInMatcher) {
|
||||
return builtInMatcher.apply(this, arguments)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for(var methodName in jQueryMatchers) {
|
||||
bindMatcher(methodName)
|
||||
}
|
||||
}()
|
||||
|
||||
beforeEach(function() {
|
||||
this.addMatchers(jasmine.JQuery.matchersClass)
|
||||
this.addMatchers({
|
||||
toHaveBeenTriggeredOn: function(selector) {
|
||||
this.message = function() {
|
||||
return [
|
||||
"Expected event " + this.actual + " to have been triggered on " + selector,
|
||||
"Expected event " + this.actual + " not to have been triggered on " + selector
|
||||
]
|
||||
}
|
||||
return jasmine.JQuery.events.wasTriggered($(selector), this.actual)
|
||||
}
|
||||
})
|
||||
this.addMatchers({
|
||||
toHaveBeenPreventedOn: function(selector) {
|
||||
this.message = function() {
|
||||
return [
|
||||
"Expected event " + this.actual + " to have been prevented on " + selector,
|
||||
"Expected event " + this.actual + " not to have been prevented on " + selector
|
||||
]
|
||||
}
|
||||
return jasmine.JQuery.events.wasPrevented(selector, this.actual)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
jasmine.getFixtures().cleanUp()
|
||||
jasmine.JQuery.events.cleanUp()
|
||||
})
|
||||
@@ -12,6 +12,7 @@ file and check it in at the same time as your model changes. To do that,
|
||||
|
||||
"""
|
||||
import logging
|
||||
import markupsafe
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
@@ -176,7 +177,7 @@ class CourseEmailTemplate(models.Model):
|
||||
which is rendered using format() with the provided `context` dict.
|
||||
|
||||
Any keywords encoded in the form %%KEYWORD%% found in the message
|
||||
body are subtituted with user data before the body is inserted into
|
||||
body are substituted with user data before the body is inserted into
|
||||
the template.
|
||||
|
||||
Output is returned as a unicode string. It is not encoded as utf-8.
|
||||
@@ -215,6 +216,10 @@ class CourseEmailTemplate(models.Model):
|
||||
Convert HTML text body (`htmltext`) into HTML email message using the
|
||||
stored HTML template and the provided `context` dict.
|
||||
"""
|
||||
# HTML-escape string values in the context (used for keyword substitution).
|
||||
for key, value in context.iteritems():
|
||||
if isinstance(value, basestring):
|
||||
context[key] = markupsafe.escape(value)
|
||||
return CourseEmailTemplate._render(self.html_template, htmltext, context)
|
||||
|
||||
|
||||
|
||||
@@ -97,6 +97,15 @@ class CourseEmailTemplateTest(TestCase):
|
||||
context['course_image_url'] = "/location/of/course/image/url"
|
||||
return context
|
||||
|
||||
def _add_xss_fields(self, context):
|
||||
""" Add fields to the context for XSS testing. """
|
||||
context['course_title'] = "<script>alert('Course Title!');</alert>"
|
||||
context['name'] = "<script>alert('Profile Name!');</alert>"
|
||||
# Must have user_id and course_id present in order to do keyword substitution
|
||||
context['user_id'] = 12345
|
||||
context['course_id'] = "course-v1:edx+100+1"
|
||||
return context
|
||||
|
||||
def test_get_template(self):
|
||||
# Get the default template, which has name=None
|
||||
template = CourseEmailTemplate.get_template()
|
||||
@@ -134,11 +143,31 @@ class CourseEmailTemplateTest(TestCase):
|
||||
context = self._get_sample_html_context()
|
||||
template.render_htmltext("My new html text.", context)
|
||||
|
||||
def test_render_html_xss(self):
|
||||
template = CourseEmailTemplate.get_template()
|
||||
context = self._add_xss_fields(self._get_sample_html_context())
|
||||
message = template.render_htmltext(
|
||||
"Dear %%USER_FULLNAME%%, thanks for enrolling in %%COURSE_DISPLAY_NAME%%.", context
|
||||
)
|
||||
self.assertNotIn("<script>", message)
|
||||
self.assertIn("<script>alert('Course Title!');</alert>", message)
|
||||
self.assertIn("<script>alert('Profile Name!');</alert>", message)
|
||||
|
||||
def test_render_plain(self):
|
||||
template = CourseEmailTemplate.get_template()
|
||||
context = self._get_sample_plain_context()
|
||||
template.render_plaintext("My new plain text.", context)
|
||||
|
||||
def test_render_plain_no_escaping(self):
|
||||
template = CourseEmailTemplate.get_template()
|
||||
context = self._add_xss_fields(self._get_sample_plain_context())
|
||||
message = template.render_plaintext(
|
||||
"Dear %%USER_FULLNAME%%, thanks for enrolling in %%COURSE_DISPLAY_NAME%%.", context
|
||||
)
|
||||
self.assertNotIn("<script>", message)
|
||||
self.assertIn(context['course_title'], message)
|
||||
self.assertIn(context['name'], message)
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
class CourseAuthorizationTest(TestCase):
|
||||
|
||||
@@ -52,6 +52,8 @@ from class_dashboard.dashboard_data import get_section_display_name, get_array_s
|
||||
from .tools import get_units_with_due_date, title_or_url, bulk_email_is_enabled_for_course
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
|
||||
from openedx.core.djangolib.markup import Text, HTML
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -111,13 +113,13 @@ def instructor_dashboard_2(request, course_id):
|
||||
if settings.ANALYTICS_DASHBOARD_URL:
|
||||
# Construct a URL to the external analytics dashboard
|
||||
analytics_dashboard_url = '{0}/courses/{1}'.format(settings.ANALYTICS_DASHBOARD_URL, unicode(course_key))
|
||||
link_start = "<a href=\"{}\" target=\"_blank\">".format(analytics_dashboard_url)
|
||||
link_start = HTML("<a href=\"{}\" target=\"_blank\">").format(analytics_dashboard_url)
|
||||
analytics_dashboard_message = _(
|
||||
"To gain insights into student enrollment and participation {link_start}"
|
||||
"visit {analytics_dashboard_name}, our new course analytics product{link_end}."
|
||||
)
|
||||
analytics_dashboard_message = analytics_dashboard_message.format(
|
||||
link_start=link_start, link_end="</a>", analytics_dashboard_name=settings.ANALYTICS_DASHBOARD_NAME)
|
||||
analytics_dashboard_message = Text(analytics_dashboard_message).format(
|
||||
link_start=link_start, link_end=HTML("</a>"), analytics_dashboard_name=settings.ANALYTICS_DASHBOARD_NAME)
|
||||
|
||||
# Temporarily show the "Analytics" section until we have a better way of linking to Insights
|
||||
sections.append(_section_analytics(course, access))
|
||||
@@ -629,8 +631,9 @@ def _section_send_email(course, access):
|
||||
def _get_dashboard_link(course_key):
|
||||
""" Construct a URL to the external analytics dashboard """
|
||||
analytics_dashboard_url = '{0}/courses/{1}'.format(settings.ANALYTICS_DASHBOARD_URL, unicode(course_key))
|
||||
link = u"<a href=\"{0}\" target=\"_blank\">{1}</a>".format(analytics_dashboard_url,
|
||||
settings.ANALYTICS_DASHBOARD_NAME)
|
||||
link = HTML(u"<a href=\"{0}\" target=\"_blank\">{1}</a>").format(
|
||||
analytics_dashboard_url, settings.ANALYTICS_DASHBOARD_NAME
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
// Configuration
|
||||
@import 'config';
|
||||
@import 'base/variables';
|
||||
@import 'base-v2/extends';
|
||||
|
||||
// Extensions
|
||||
@import 'shared-v2/base';
|
||||
@import 'shared-v2/navigation';
|
||||
@import 'shared-v2/header';
|
||||
@import 'shared-v2/footer';
|
||||
|
||||
6
lms/static/sass/base-v2/_extends.scss
Normal file
6
lms/static/sass/base-v2/_extends.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
// Adds a simple extend that indicates that this user interface element should not print
|
||||
%ui-print-excluded {
|
||||
@media print {
|
||||
display:none;
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,42 @@
|
||||
border-top: 3px solid $blue;
|
||||
padding: $baseline 0;
|
||||
|
||||
.copy {
|
||||
@extend %t-copy-sub1;
|
||||
}
|
||||
|
||||
.btn-find-courses {
|
||||
@extend %btn-pl-elevated-alt;
|
||||
.course-advertise {
|
||||
@include clearfix();
|
||||
box-sizing: border-box;
|
||||
padding: $baseline;
|
||||
background-color: $body-bg;
|
||||
border: 1px solid $border-color-l3;
|
||||
.advertise-message {
|
||||
@include font-size(12);
|
||||
color: $gray-d4;
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
.ad-link {
|
||||
@include text-align(center);
|
||||
.btn-find-courses {
|
||||
padding-bottom: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
a {
|
||||
@include font-size(16);
|
||||
@include line-height(1.2);
|
||||
padding: $baseline * 0.5;
|
||||
border: 1px solid $blue;
|
||||
color: $blue;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: $white;
|
||||
background-color: $blue;
|
||||
}
|
||||
span {
|
||||
@include margin-left($baseline*0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
196
lms/static/sass/shared-v2/_footer.scss
Normal file
196
lms/static/sass/shared-v2/_footer.scss
Normal file
@@ -0,0 +1,196 @@
|
||||
// Open edX: LMS footer
|
||||
// ====================
|
||||
|
||||
.wrapper-footer {
|
||||
@extend %ui-print-excluded;
|
||||
margin-top: ($baseline*2) + px;
|
||||
box-shadow: 0 -1px 5px 0 $shadow-l1;
|
||||
border-top: 1px solid tint(palette(grayscale, light), 50%);
|
||||
padding: 25px ($baseline/2 + px) ($baseline*1.5 + px) ($baseline/2 + px);
|
||||
background: $footer-bg;
|
||||
clear: both;
|
||||
|
||||
footer#footer-openedx {
|
||||
@include clearfix();
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
|
||||
p, ol, ul {
|
||||
font-family: $sans-serif;
|
||||
|
||||
// override needed for poorly scoped font-family styling on p a:link {}
|
||||
a {
|
||||
font-family: $sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@extend %link-text;
|
||||
border-bottom: none;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border-bottom: 1px dotted $link-color;
|
||||
}
|
||||
}
|
||||
|
||||
// colophon
|
||||
.colophon {
|
||||
@include span(12);
|
||||
|
||||
@include susy-media($bp-screen-sm) {
|
||||
@include span(8);
|
||||
}
|
||||
|
||||
.nav-colophon {
|
||||
@include clearfix();
|
||||
margin: $footer_margin;
|
||||
|
||||
li {
|
||||
@include float(left);
|
||||
margin-right: ($baseline*0.75) + px;
|
||||
|
||||
a {
|
||||
color: tint($black, 20%);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: $link-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@include margin-right(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.colophon-about {
|
||||
@include clearfix();
|
||||
|
||||
img {
|
||||
@include float(left);
|
||||
width: 68px;
|
||||
height: 34px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
@include float(left);
|
||||
@include span(9);
|
||||
margin-left: $baseline + px;
|
||||
padding-left: $baseline + px;
|
||||
font-size: font-size(small);
|
||||
background: transparent url(/static/images/bg-footer-divider.jpg) 0 0 no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// references
|
||||
.references {
|
||||
@include span(4);
|
||||
margin: -10px 0 0 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wrapper-logo {
|
||||
margin: ($baseline*0.75) + px 0;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copyright {
|
||||
@include text-align(left);
|
||||
margin: -2px 0 8px 0;
|
||||
font-size: font-size(xx-small);
|
||||
color: palette(grayscale, dark);
|
||||
}
|
||||
|
||||
.nav-legal {
|
||||
@include clearfix();
|
||||
@include text-align(left);
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
font-size: font-size(xx-small);
|
||||
}
|
||||
|
||||
.nav-legal-02 a {
|
||||
&:before {
|
||||
@include margin-right(($baseline/4) + px);
|
||||
content: "-";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-social {
|
||||
@include text-align(right);
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// platform Open edX logo and link
|
||||
.footer-about-openedx {
|
||||
@include span(12);
|
||||
@include text-align(right);
|
||||
vertical-align: bottom;
|
||||
|
||||
@include susy-media($bp-screen-sm) {
|
||||
@include span(4);
|
||||
@include margin-right(0);
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
@include float(right);
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// marketing site design syncing
|
||||
.view-register,
|
||||
.view-login,
|
||||
.view-passwordreset {
|
||||
.wrapper-footer footer {
|
||||
width: 960px;
|
||||
|
||||
.colophon-about img {
|
||||
margin-top: ($baseline*1.5) + px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.header-global {
|
||||
@extend %ui-depth1;
|
||||
@include box-sizing(border-box);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-bottom: 4px solid $courseware-border-bottom-color;
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.wrapper-header {
|
||||
@include clearfix();
|
||||
@include box-sizing(border-box);
|
||||
box-sizing: border-box;
|
||||
height: 74px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 10px 0;
|
||||
|
||||
@@ -17,6 +17,10 @@ footer#footer-edx-v3 {
|
||||
background: $edx-footer-bg-color;
|
||||
padding: 20px;
|
||||
border-top: 1px solid $courseware-button-border-color;
|
||||
|
||||
// To match the Pattern Library
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
.footer-content-wrapper {
|
||||
@include outer-container;
|
||||
|
||||
@@ -191,9 +191,7 @@
|
||||
|
||||
// edx theme overrides
|
||||
&.edx-footer {
|
||||
|
||||
footer {
|
||||
|
||||
.copyright {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -216,86 +214,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// edX theme: LMS Footer
|
||||
// ====================
|
||||
$edx-footer-spacing: ($baseline*0.75);
|
||||
$edx-footer-link-color: $link-color;
|
||||
$edx-footer-bg-color: rgb(252,252,252);
|
||||
|
||||
%edx-footer-reset {
|
||||
@include box-sizing(border-box);
|
||||
}
|
||||
|
||||
%edx-footer-section {
|
||||
@include float(left);
|
||||
min-height: ($baseline*17.5);
|
||||
@include margin-right(flex-gutter());
|
||||
@include border-right(1px solid rgb(230, 230, 230));
|
||||
@include padding-right($baseline*1.5);
|
||||
|
||||
// CASE: last child
|
||||
&:last-child {
|
||||
@include margin-right(0);
|
||||
border: none;
|
||||
@include padding-right(0);
|
||||
}
|
||||
}
|
||||
|
||||
%edx-footer-title {
|
||||
// TODO: refactor _typography.scss to extend this set of styling
|
||||
@extend %t-title;
|
||||
@extend %t-weight4;
|
||||
@include font-size(15);
|
||||
@include line-height(15);
|
||||
text-transform: none;
|
||||
letter-spacing: inherit;
|
||||
color: rgb(61, 62, 63);
|
||||
}
|
||||
|
||||
%edx-footer-link {
|
||||
@extend %t-copy-sub1;
|
||||
@include transition(color $tmg-f2 ease-in-out 0);
|
||||
display: block;
|
||||
margin-bottom: ($baseline/2);
|
||||
|
||||
// NOTE: resetting poor link styles
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: $edx-footer-link-color;
|
||||
|
||||
.copy {
|
||||
@include transition(border-color $tmg-f2 ease-in-out 0);
|
||||
display: inline-block;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 0 0 ($baseline/20) 0;
|
||||
color: $edx-footer-link-color;
|
||||
}
|
||||
|
||||
// STATE: hover + focused
|
||||
&:hover, &:focus {
|
||||
color: saturate($edx-footer-link-color, 25%);
|
||||
|
||||
// NOTE: resetting poor link styles
|
||||
border: none;
|
||||
|
||||
.copy {
|
||||
border-bottom-color: saturate($edx-footer-link-color, 25%);
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: last child
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// CASE: has visual emphasis
|
||||
&.has-emphasis {
|
||||
@extend %t-weight4;
|
||||
|
||||
.copy {
|
||||
@extend %t-weight4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<div class="wrapper wrapper-footer">
|
||||
<footer id="footer-openedx"
|
||||
<footer id="footer-openedx" class="grid-container"
|
||||
## When rendering the footer through the branding API,
|
||||
## the direction may not be set on the parent element,
|
||||
## so we set it here.
|
||||
|
||||
@@ -71,7 +71,7 @@ site_status_msg = get_site_status_msg(course_id)
|
||||
% endif
|
||||
|
||||
% if user.is_authenticated():
|
||||
<ol class="left nav-global authenticated">
|
||||
<ol class="left nav-global list-inline authenticated">
|
||||
<%block name="navigation_global_links_authenticated">
|
||||
% if settings.FEATURES.get('COURSES_ARE_BROWSABLE') and not show_program_listing:
|
||||
<li class="item nav-global-01">
|
||||
|
||||
@@ -157,10 +157,19 @@ from openedx.core.djangolib.markup import Text, HTML
|
||||
% endif
|
||||
|
||||
% if settings.FEATURES.get('COURSES_ARE_BROWSABLE'):
|
||||
<div class="wrapper-find-courses">
|
||||
<p class="copy">${_("Check out our recently launched courses and what's new in your favorite subjects")}</p>
|
||||
<p><a class="btn-find-courses" href="${marketing_link('COURSES')}">${_("Find New Courses")}</a></p>
|
||||
</div>
|
||||
<div class="wrapper-find-courses">
|
||||
<div class="course-advertise">
|
||||
<div class="advertise-message">
|
||||
${_("Browse recently launched courses and see what's new in your favorite subjects.")}
|
||||
</div>
|
||||
<div class="ad-link">
|
||||
<a class="btn-find-courses" href="${marketing_link('COURSES')}">
|
||||
<span class="icon fa fa-search" aria-hidden="true"></span>
|
||||
${_("Explore New Courses")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
<section class="profile-sidebar" id="profile-sidebar" role="region" aria-label="Account Status Info">
|
||||
|
||||
Reference in New Issue
Block a user