Merge pull request #15643 from edx/andya/site-theme-preview
Implement theme admin UI to support previewing
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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"/>
|
||||
@@ -75,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():
|
||||
|
||||
13
cms/templates/fragments/standalone-page-bootstrap.html
Normal file
13
cms/templates/fragments/standalone-page-bootstrap.html
Normal file
@@ -0,0 +1,13 @@
|
||||
## 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>
|
||||
15
cms/templates/fragments/standalone-page-fragment.html
Normal file
15
cms/templates/fragments/standalone-page-fragment.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<%! 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>
|
||||
10
cms/templates/fragments/standalone-page-v1.html
Normal file
10
cms/templates/fragments/standalone-page-v1.html
Normal file
@@ -0,0 +1,10 @@
|
||||
## 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>
|
||||
13
cms/templates/fragments/standalone-page-v2.html
Normal file
13
cms/templates/fragments/standalone-page-v2.html
Normal file
@@ -0,0 +1,13 @@
|
||||
## 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>
|
||||
@@ -1,20 +1,9 @@
|
||||
## 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>
|
||||
|
||||
@@ -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')),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, 143),
|
||||
(ModuleStoreEnum.Type.split, 4, 143),
|
||||
)
|
||||
@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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// LMS layouts
|
||||
|
||||
.content-wrapper {
|
||||
margin-top: $baseline;
|
||||
|
||||
.course-tabs {
|
||||
padding-bottom: none;
|
||||
padding-bottom: 0;
|
||||
|
||||
.nav-item {
|
||||
&.active, &:hover{
|
||||
|
||||
11
lms/templates/fragments/standalone-page-bootstrap.html
Normal file
11
lms/templates/fragments/standalone-page-bootstrap.html
Normal file
@@ -0,0 +1,11 @@
|
||||
## 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"/>
|
||||
@@ -1,16 +1,9 @@
|
||||
## 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>
|
||||
8
lms/templates/fragments/standalone-page-v1.html
Normal file
8
lms/templates/fragments/standalone-page-v1.html
Normal file
@@ -0,0 +1,8 @@
|
||||
## 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"/>
|
||||
11
lms/templates/fragments/standalone-page-v2.html
Normal file
11
lms/templates/fragments/standalone-page-v2.html
Normal file
@@ -0,0 +1,11 @@
|
||||
## 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"/>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Django models supporting the Comprehensive Theming subsystem
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.db import models
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
## 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>
|
||||
@@ -1,14 +1,20 @@
|
||||
"""
|
||||
Tests for middleware for comprehensive themes.
|
||||
"""
|
||||
from mock import Mock
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
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()
|
||||
|
||||
@override_settings(DEFAULT_SITE_THEME="test-theme")
|
||||
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_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)
|
||||
|
||||
105
openedx/core/djangoapps/theming/tests/test_views.py
Normal file
105
openedx/core/djangoapps/theming/tests/test_views.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
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)
|
||||
)
|
||||
18
openedx/core/djangoapps/theming/urls.py
Normal file
18
openedx/core/djangoapps/theming/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
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',
|
||||
),
|
||||
]
|
||||
135
openedx/core/djangoapps/theming/views.py
Normal file
135
openedx/core/djangoapps/theming/views.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
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])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -160,7 +160,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
|
||||
course_home_url(self.course)
|
||||
|
||||
# Fetch the view and verify the query counts
|
||||
with self.assertNumQueries(37, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with self.assertNumQueries(38, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with check_mongo_calls(4):
|
||||
url = course_home_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
|
||||
course_updates_url(self.course)
|
||||
|
||||
# Fetch the view and verify that the query counts haven't changed
|
||||
with self.assertNumQueries(30, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with self.assertNumQueries(31, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with check_mongo_calls(4):
|
||||
url = course_updates_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Color overrides
|
||||
$white: rgb(255,255,255);
|
||||
$red: #d9534f !default;
|
||||
|
||||
$footer-bg: $white;
|
||||
$header-bg: $white;
|
||||
$header-border-color: $red;
|
||||
|
||||
$base-font-color: $red;
|
||||
$link-color: $red;
|
||||
$lms-active-color: $red;
|
||||
$lms-label-color: $red;
|
||||
|
||||
@import 'lms/static/sass/partials/base/variables';
|
||||
@@ -3,7 +3,7 @@
|
||||
// Theme colors
|
||||
//
|
||||
// Note: define colors needed by your theme first
|
||||
$red: #d9534f !default;
|
||||
$red: #d9534f;
|
||||
$brand-primary: $red;
|
||||
|
||||
// Theme fonts
|
||||
Reference in New Issue
Block a user