Commit f5223a36 by Douglas Hall

Merge branch 'master' into malikshahzad228/additional_course_fields

parents d64e4b06 6d315a9a
......@@ -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):
......
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("&lt;script&gt;alert(&#39;Course Title!&#39;);&lt;/alert&gt;", message)
self.assertIn("&lt;script&gt;alert(&#39;Profile Name!&#39;);&lt;/alert&gt;", 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("&lt;script&gt;", 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';
// 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);
}
}
}
}
}
......
// 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">
......
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