Merge "Dynamic Themes"
This commit is contained in:
commit
f9aaa76673
@ -11,11 +11,42 @@ through the use of a theme. A theme is a directory containing a
|
||||
and a ``_styles.scss`` file with additional styles to load after dashboard
|
||||
styles have loaded.
|
||||
|
||||
To use a custom theme, set ``CUSTOM_THEME_PATH`` in ``local_settings.py`` to
|
||||
the directory location for the theme (e.g., ``"themes/material"``). The
|
||||
path can either be relative to the ``openstack_dashboard`` directory or an
|
||||
absolute path to an accessible location on the file system. The default
|
||||
``CUSTOM_THEME_PATH`` is ``themes/default``.
|
||||
As of the Mitaka release, Horizon can be configured to run with multiple
|
||||
themes available at run time. It uses a browser cookie to allow users to
|
||||
toggle between the configured themes. By default, Horizon is configured
|
||||
with the two standard themes available: 'default' and 'material'.
|
||||
|
||||
To configure or alter the available themes, set ``AVAILABLE_THEMES`` in
|
||||
``local_settings.py`` to a list of tuples, such that ``('name', 'label', 'path')``
|
||||
|
||||
``name``
|
||||
The key by which the theme value is stored within the cookie
|
||||
|
||||
``label``
|
||||
The label shown in the theme toggle under the User Menu
|
||||
|
||||
``path``
|
||||
The directory location for the theme. The path must be relative to the
|
||||
``openstack_dashboard`` directory or an absolute path to an accessible
|
||||
location on the file system
|
||||
|
||||
To use a custom theme, set ``AVAILABLE_THEMES`` in ``local_settings.py`` to
|
||||
a list of themes. If you wish to run in a mode similar to legacy Horizon,
|
||||
set ``AVAILABLE_THEMES`` with a single tuple, and the theme toggle will not
|
||||
be available at all through the application to allow user configuration themes.
|
||||
|
||||
For example, a configuration with multiple themes::
|
||||
|
||||
AVAILABLE_THEMES = [
|
||||
('default', 'Default', 'themes/default'),
|
||||
('material', 'Material', 'themes/material'),
|
||||
]
|
||||
|
||||
A configuration with a single theme::
|
||||
|
||||
AVAILABLE_THEMES = [
|
||||
('default', 'Default', 'themes/default'),
|
||||
]
|
||||
|
||||
Both the Dashboard custom variables and Bootstrap variables can be overridden.
|
||||
For a full list of the Dashboard SCSS variables that can be changed, see the
|
||||
@ -39,40 +70,31 @@ theme's ``_variables.scss``::
|
||||
Once you have made your changes you must re-generate the static files with
|
||||
``./run_tests.py -m collectstatic``.
|
||||
|
||||
The Default Theme
|
||||
~~~~~~~~~~~~~~~~~
|
||||
By default, all of the themes configured by ``AVAILABLE_THEMES`` setting are
|
||||
collected by horizon during the `collectstatic` process. By default, the themes
|
||||
are collected into the dynamic `static/themes` directory, but this location can
|
||||
be customized via the ``local_settings.py`` variable: ``THEME_COLLECTION_DIR``
|
||||
|
||||
By default, only the themes configured by the settings: `DEFAULT_THEME_PATH`
|
||||
and `CUSTOM_THEME_PATH` are collected during the `collectstatic` process into
|
||||
the dynamic `static` directory into the following directories::
|
||||
Once collected, any theme configured via ``AVAILABLE_THEMES`` is available to
|
||||
inherit from by importing its variables and styles from its collection
|
||||
directory. The following is an example of inheriting from the material theme::
|
||||
|
||||
CUSTOM_THEME_PATH: /custom
|
||||
DEFAULT_THEME_PATH: /themes/default
|
||||
@import "/themes/material/variables";
|
||||
@import "/themes/material/styles";
|
||||
|
||||
Bootswatch
|
||||
~~~~~~~~~~
|
||||
|
||||
.. NOTE::
|
||||
|
||||
However, if `DEFAULT_THEME_PATH` and `CUSTOM_THEME_PATH` are equal, then the
|
||||
only directory that will be collected into `static` is `/custom`.
|
||||
|
||||
By default, `DEFAULT_THEME_PATH` is set to the 'default' theme path, therefore
|
||||
if you wish to inherit from another theme (i.e. `material`) that will need to
|
||||
be collected from the Horizon code base, then you just update
|
||||
`DEFAULT_THEME_PATH` to ensure that the theme you wish to inherit from is
|
||||
available in the `static` directory.
|
||||
|
||||
If you need to inherit from a Bootswatch theme, no further changes to settings
|
||||
are necessary. This is due to the fact that Bootswatch is loaded as a 3rd
|
||||
party static asset, and therefore is automatically collected into the `static`
|
||||
directory in `/horizon/lib/`. Just add @imports to your theme's scss files::
|
||||
Horizon packages the Bootswatch SCSS files for use with its ``material`` theme.
|
||||
Because of this, it is simple to use an existing Bootswatch theme as a base.
|
||||
This is due to the fact that Bootswatch is loaded as a 3rd party static asset,
|
||||
and therefore is automatically collected into the `static` directory in
|
||||
`/horizon/lib/`. The following is an example of how to inherit from Bootswatch's
|
||||
``darkly`` theme::
|
||||
|
||||
@import "/horizon/lib/bootswatch/darkly/variables";
|
||||
@import "/horizon/lib/bootswatch/darkly/bootswatch";
|
||||
|
||||
.. NOTE::
|
||||
|
||||
The above only shows how to import the 'darkly' theme as an example, but any
|
||||
of the Bootswatch theme can be imported this way.
|
||||
|
||||
Organizing Your Theme Directory
|
||||
-------------------------------
|
||||
@ -108,17 +130,18 @@ directory structure that the extending template expects.
|
||||
For example, if you wish to customize the sidebar, Horizon expects the template
|
||||
to live at ``horizon/_sidebar.html``. You would need to duplicate that
|
||||
directory structure under your templates directory, such that your override
|
||||
would live at ``{CUSTOM_THEME_PATH}/templates/horizon/_sidebar.html``.
|
||||
would live at ``{ theme_path }/templates/horizon/_sidebar.html``.
|
||||
|
||||
The ``img`` Folder
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If the static root of the theme folder contains an ``img`` directory,
|
||||
then all images contained within ``dashboard/img`` can be overridden by
|
||||
providing a file with the same name.
|
||||
then all images that make use of the {% themable_asset %} templatetag
|
||||
can be overridden.
|
||||
|
||||
For a complete list of the images that can be overridden this way, see:
|
||||
``openstack_dashboard/static/dashboard/img``
|
||||
These assets include logo.png, splash-logo.png and favicon.ico, however
|
||||
overriding the SVG/GIF assets used by Heat within the `dashboard/img` folder
|
||||
is not currently supported.
|
||||
|
||||
Customizing the Logo
|
||||
--------------------
|
||||
|
@ -422,6 +422,85 @@ This example sorts flavors by vcpus in descending order::
|
||||
'reverse': True,
|
||||
}
|
||||
|
||||
.. _available_themes:
|
||||
|
||||
``AVAILABLE_THEMES``
|
||||
--------------------
|
||||
|
||||
.. versionadded:: 9.0.0(Mitaka)
|
||||
|
||||
Default: ``AVAILABLE_THEMES = [
|
||||
('default', 'Default', 'themes/default'),
|
||||
('material', 'Material', 'themes/material'),
|
||||
]``
|
||||
|
||||
This setting tells Horizon which themes to use.
|
||||
|
||||
A list of tuples which define multiple themes. The tuple format is
|
||||
``('{{ theme_name }}', '{{ theme_label }}', '{{ theme_path }}')``.
|
||||
|
||||
The ``theme_name`` is the name used to define the directory which
|
||||
the theme is collected into, under ``/{{ THEME_COLLECTION_DIR }}``.
|
||||
It also specifies the key by which the selected theme is stored in
|
||||
the browser's cookie.
|
||||
|
||||
The ``theme_label`` is the user-facing label that is shown in the
|
||||
theme picker. The theme picker is only visible if more than one
|
||||
theme is configured, and shows under the topnav's user menu.
|
||||
|
||||
By default, the ``theme path`` is the directory that will serve as
|
||||
the static root of the theme and the entire contents of the directory
|
||||
is served up at ``/{{ THEME_COLLECTION_DIR }}/{{ theme_name }}``.
|
||||
If you wish to include content other than static files in a theme
|
||||
directory, but do not wish that content to be served up, then you
|
||||
can create a sub directory named ``static``. If the theme folder
|
||||
contains a sub-directory with the name ``static``, then
|
||||
``static/custom/static``` will be used as the root for the content
|
||||
served at ``/static/custom``.
|
||||
|
||||
The static root of the theme folder must always contain a _variables.scss
|
||||
file and a _styles.scss file. These must contain or import all the
|
||||
bootstrap and horizon specific variables and styles which are used to style
|
||||
the GUI. For example themes, see: /horizon/openstack_dashboard/themes/
|
||||
|
||||
Horizon ships with two themes configured. 'default' is the default theme,
|
||||
and 'material' is based on Google's Material Design.
|
||||
|
||||
``DEFAULT_THEME``
|
||||
-----------------
|
||||
|
||||
.. versionadded:: 9.0.0(Mitaka)
|
||||
|
||||
Default: ``"default"``
|
||||
|
||||
This setting tells Horizon which theme to use if the user has not
|
||||
yet selected a theme through the theme picker and therefore set the
|
||||
cookie value. This value represents the ``theme_name`` key that is
|
||||
used from ``AVAILABLE_THEMES``. To use this setting, the theme must
|
||||
also be configured inside of ``AVAILABLE_THEMES``.
|
||||
|
||||
``THEME_COLLECTION_DIR``
|
||||
------------------------
|
||||
|
||||
.. versionadded:: 9.0.0(Mitaka)
|
||||
|
||||
Default: ``"themes"``
|
||||
|
||||
This setting tells Horizon which static directory to collect the
|
||||
available themes into, and therefore which URL points to the theme
|
||||
colleciton root. For example, the default theme would be accessible
|
||||
via ``/{{ STATIC_URL }}/themes/default``.
|
||||
|
||||
``THEME_COOKIE_NAME``
|
||||
---------------------
|
||||
|
||||
.. versionadded:: 9.0.0(Mitaka)
|
||||
|
||||
Default: ``"theme"``
|
||||
|
||||
This setting tells Horizon in which cookie key to store the currently
|
||||
set theme. The cookie expiration is currently set to a year.
|
||||
|
||||
.. _custom_theme_path:
|
||||
|
||||
``CUSTOM_THEME_PATH``
|
||||
@ -429,6 +508,8 @@ This example sorts flavors by vcpus in descending order::
|
||||
|
||||
.. versionadded:: 2015.1(Kilo)
|
||||
|
||||
(Deprecated)
|
||||
|
||||
Default: ``"themes/default"``
|
||||
|
||||
This setting tells Horizon to use a directory as a custom theme.
|
||||
@ -450,12 +531,17 @@ the GUI. For example themes, see: /horizon/openstack_dashboard/themes/
|
||||
Horizon ships with one alternate theme based on Google's Material Design. To
|
||||
use the alternate theme, set your CUSTOM_THEME_PATH to ``themes/material``.
|
||||
|
||||
This option is now marked as "deprecated" and will be removed in Newton or
|
||||
a later release. Themes are now controlled by AVAILABLE_THEMES. We suggest
|
||||
changing your custom theme settings to use this option instead.
|
||||
|
||||
``DEFAULT_THEME_PATH``
|
||||
----------------------
|
||||
|
||||
.. versionadded:: 8.0.0(Liberty)
|
||||
|
||||
(Deprecated)
|
||||
|
||||
Default: ``"themes/default"``
|
||||
|
||||
This setting allows Horizon to collect an additional theme during static
|
||||
@ -465,6 +551,8 @@ if CUSTOM_THEME_PATH inherits from another theme (like 'default').
|
||||
If DEFAULT_THEME_PATH is the same as CUSTOM_THEME_PATH, then collection
|
||||
is skipped and /static/themes will not exist.
|
||||
|
||||
This option is now marked as "deprecated" and will be removed in Newton or
|
||||
a later release. Themes are now controlled by AVAILABLE_THEMES.
|
||||
|
||||
``DROPDOWN_MAX_ITEMS``
|
||||
----------------------
|
||||
|
@ -120,5 +120,4 @@ A second theme is provided by default at
|
||||
``openstack_dashboard/themes/material/``. When adding new SCSS to horizon, you
|
||||
should check that it does not interfere with the Material theme. Images of how
|
||||
the Material theme should look can be found at https://bootswatch.com/paper/.
|
||||
To set up this theme, see the :ref:`custom_theme_path` entry in our settings
|
||||
documentation.
|
||||
This theme is now configured to run as the alternate theme within Horizon.
|
||||
|
@ -1,3 +1,5 @@
|
||||
{% load themes %}
|
||||
|
||||
<div class="text-center">
|
||||
<img class="splash-logo" src="{{ STATIC_URL }}dashboard/img/logo-splash.png">
|
||||
<img class="splash-logo" src={% themable_asset "img/logo-splash.png" %}>
|
||||
</div>
|
||||
|
164
horizon/themes.py
Normal file
164
horizon/themes.py
Normal file
@ -0,0 +1,164 @@
|
||||
# Copyright 2016 Hewlett Packard Enterprise Software, LLC
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Allows Dynamic Theme Loading.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousFileOperation
|
||||
from django.template.engine import Engine
|
||||
from django.template.loaders.base import Loader as tLoaderCls
|
||||
from django.utils._os import safe_join # noqa
|
||||
|
||||
if django.VERSION >= (1, 9):
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
else:
|
||||
from django.template.base import TemplateDoesNotExist # noqa
|
||||
|
||||
|
||||
# Local thread storage to retrieve the currently set theme
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
# Get the themes from settings
|
||||
def get_themes():
|
||||
return getattr(settings, 'AVAILABLE_THEMES', [])
|
||||
|
||||
|
||||
# Get the themes dir from settings
|
||||
def get_theme_dir():
|
||||
return getattr(settings, 'THEME_COLLECTION_DIR', 'themes')
|
||||
|
||||
|
||||
# Get the theme cookie name from settings
|
||||
def get_theme_cookie_name():
|
||||
return getattr(settings, 'THEME_COOKIE_NAME', 'theme')
|
||||
|
||||
|
||||
# Get the default theme
|
||||
def get_default_theme():
|
||||
return getattr(settings, 'DEFAULT_THEME', 'default')
|
||||
|
||||
|
||||
# Find the theme tuple
|
||||
def find_theme(theme_name):
|
||||
for each_theme in get_themes():
|
||||
if theme_name == each_theme[0]:
|
||||
return each_theme
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Offline Context Generator
|
||||
def offline_context():
|
||||
for theme in get_themes():
|
||||
base_context = \
|
||||
getattr(
|
||||
settings,
|
||||
'HORIZON_COMPRESS_OFFLINE_CONTEXT_BASE',
|
||||
{}
|
||||
).copy()
|
||||
base_context['THEME'] = theme[0]
|
||||
base_context['THEME_DIR'] = get_theme_dir()
|
||||
yield base_context
|
||||
|
||||
|
||||
# A piece of middleware that stores the theme cookie value into
|
||||
# local thread storage so the template loader can access it
|
||||
class ThemeMiddleware(object):
|
||||
"""The Theme Middleware component. The custom template loaders
|
||||
don't have access to the request object, so we need to store
|
||||
the Cookie's theme value for use later in the Django chain.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
|
||||
# Determine which theme the user has configured and store in local
|
||||
# thread storage so that it persists to the custom template loader
|
||||
try:
|
||||
_local.theme = request.COOKIES[get_theme_cookie_name()]
|
||||
except KeyError:
|
||||
_local.theme = get_default_theme()
|
||||
|
||||
def process_response(self, request, response):
|
||||
try:
|
||||
delattr(_local, 'theme')
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ThemeTemplateLoader(tLoaderCls):
|
||||
"""Themes can contain template overrides, so we need to check the
|
||||
theme directory first, before loading any of the standard templates.
|
||||
"""
|
||||
is_usable = True
|
||||
|
||||
def get_template_sources(self, template_name):
|
||||
|
||||
# If the cookie doesn't exist, set it to the default theme
|
||||
default_theme = get_default_theme()
|
||||
theme = getattr(_local, 'theme', default_theme)
|
||||
this_theme = find_theme(theme)
|
||||
|
||||
# If the theme is not valid, check the default theme ...
|
||||
if not this_theme:
|
||||
this_theme = find_theme(get_default_theme())
|
||||
|
||||
# If the theme is still not valid, then move along ...
|
||||
# these aren't the templates you are looking for
|
||||
if not this_theme:
|
||||
pass
|
||||
|
||||
try:
|
||||
if not template_name.startswith('/'):
|
||||
try:
|
||||
yield safe_join(
|
||||
'openstack_dashboard',
|
||||
this_theme[2],
|
||||
'templates',
|
||||
template_name
|
||||
)
|
||||
except SuspiciousFileOperation:
|
||||
yield os.path.join(
|
||||
this_theme[2], 'templates', template_name
|
||||
)
|
||||
|
||||
except UnicodeDecodeError:
|
||||
# The template dir name wasn't valid UTF-8.
|
||||
raise
|
||||
except ValueError:
|
||||
# The joined path was located outside of template_dir.
|
||||
pass
|
||||
|
||||
def load_template_source(self, template_name, template_dirs=None):
|
||||
for path in self.get_template_sources(template_name):
|
||||
try:
|
||||
with io.open(path, encoding=settings.FILE_CHARSET) as file:
|
||||
return file.read(), path
|
||||
except IOError:
|
||||
pass
|
||||
raise TemplateDoesNotExist(template_name)
|
||||
|
||||
|
||||
e = Engine()
|
||||
_loader = ThemeTemplateLoader(e)
|
@ -1,5 +1,3 @@
|
||||
@import "/custom/variables";
|
||||
|
||||
themepreview {
|
||||
|
||||
#source-button {
|
||||
|
@ -1,12 +1,19 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load themes %}
|
||||
|
||||
{% block title %}
|
||||
{% trans "Theme Preview" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<h1>{{ skin }} <small>{{ skin_desc }}</small></h1>
|
||||
{% current_theme as current_theme %}
|
||||
{% themes as available_themes %}
|
||||
{% for theme in available_themes %}
|
||||
{% if current_theme == theme.0 %}
|
||||
<h1>{{ theme.1 }} <small>{{ theme.2 }}</small></h1>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
@ -12,7 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import views
|
||||
@ -21,10 +20,3 @@ from horizon import views
|
||||
class IndexView(views.HorizonTemplateView):
|
||||
template_name = 'developer/theme_preview/index.html'
|
||||
page_title = _("Bootstrap Theme Preview")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
theme_path = settings.CUSTOM_THEME_PATH
|
||||
context = super(IndexView, self).get_context_data(**kwargs)
|
||||
context['skin'] = theme_path.split('/')[-1]
|
||||
context['skin_desc'] = theme_path
|
||||
return context
|
||||
|
@ -1,3 +0,0 @@
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
@import "/dashboard/scss/variables";
|
@ -1,9 +1,2 @@
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
@import "/dashboard/scss/variables";
|
||||
|
||||
@import "users/users";
|
||||
@import "projects/projects";
|
||||
|
||||
// Custom Style Variables
|
||||
@import "/custom/styles";
|
||||
|
@ -1,9 +1 @@
|
||||
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
|
||||
@import "/dashboard/scss/variables";
|
||||
@import "workflow/workflow";
|
||||
|
||||
// Custom Style Variables
|
||||
@import "/custom/styles";
|
||||
|
@ -25,7 +25,3 @@ ADD_ANGULAR_MODULES = [
|
||||
]
|
||||
|
||||
AUTO_DISCOVER_STATIC_FILES = True
|
||||
|
||||
ADD_SCSS_FILES = [
|
||||
'dashboard/admin/admin.scss'
|
||||
]
|
||||
|
@ -1,2 +1,2 @@
|
||||
# override the CUSTOM_THEME_PATH variable with this settings snippet
|
||||
# CUSTOM_THEME_PATH="themes/material"
|
||||
# AVAILABLE_THEMES=[('material', 'Material', 'themes/material')]
|
||||
|
@ -430,9 +430,13 @@ TIME_ZONE = "UTC"
|
||||
#TROVE_ADD_USER_PERMS = []
|
||||
#TROVE_ADD_DATABASE_PERMS = []
|
||||
|
||||
# Change this patch to the appropriate static directory containing
|
||||
# two files: _variables.scss and _styles.scss
|
||||
#CUSTOM_THEME_PATH = 'themes/default'
|
||||
# Change this patch to the appropriate list of tuples containing
|
||||
# a key, label and static directory containing two files:
|
||||
# _variables.scss and _styles.scss
|
||||
#AVAILABLE_THEMES = [
|
||||
# ('default', 'Default', 'themes/default'),
|
||||
# ('material', 'Material', 'themes/material'),
|
||||
#]
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
|
@ -21,11 +21,13 @@ import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from django.utils.translation import pgettext_lazy
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from openstack_dashboard import exceptions
|
||||
from openstack_dashboard.static_settings import find_static_files # noqa
|
||||
from openstack_dashboard.static_settings import get_staticfiles_dirs # noqa
|
||||
from openstack_dashboard import theme_settings
|
||||
|
||||
|
||||
warnings.formatwarning = lambda message, category, *args, **kwargs: \
|
||||
@ -105,6 +107,7 @@ MIDDLEWARE_CLASSES = (
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
|
||||
'horizon.middleware.HorizonMiddleware',
|
||||
'horizon.themes.ThemeMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
)
|
||||
@ -121,6 +124,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
)
|
||||
|
||||
TEMPLATE_LOADERS = (
|
||||
'horizon.themes.ThemeTemplateLoader',
|
||||
('django.template.loaders.cached.Loader', (
|
||||
'django.template.loaders.filesystem.Loader',
|
||||
'django.template.loaders.app_directories.Loader',
|
||||
@ -257,10 +261,31 @@ SECURITY_GROUP_RULES = {
|
||||
|
||||
ADD_INSTALLED_APPS = []
|
||||
|
||||
# directory for custom theme, set as default.
|
||||
# It can be overridden in local_settings.py
|
||||
DEFAULT_THEME_PATH = 'themes/default'
|
||||
CUSTOM_THEME_PATH = DEFAULT_THEME_PATH
|
||||
# Deprecated Theme Settings
|
||||
CUSTOM_THEME_PATH = None
|
||||
DEFAULT_THEME_PATH = None
|
||||
|
||||
# 'key', 'label', 'path'
|
||||
AVAILABLE_THEMES = [
|
||||
(
|
||||
'default',
|
||||
pgettext_lazy('Default style theme', 'Default'),
|
||||
'themes/default'
|
||||
), (
|
||||
'material',
|
||||
pgettext_lazy("Google's Material Design style theme", "Material"),
|
||||
'themes/material'
|
||||
),
|
||||
]
|
||||
|
||||
# The default theme if no cookie is present
|
||||
DEFAULT_THEME = 'default'
|
||||
|
||||
# Theme Static Directory
|
||||
THEME_COLLECTION_DIR = 'themes'
|
||||
|
||||
# Theme Cookie Name
|
||||
THEME_COOKIE_NAME = 'theme'
|
||||
|
||||
try:
|
||||
from local.local_settings import * # noqa
|
||||
@ -298,39 +323,26 @@ if STATIC_ROOT is None:
|
||||
if STATIC_URL is None:
|
||||
STATIC_URL = WEBROOT + 'static/'
|
||||
|
||||
STATICFILES_DIRS = get_staticfiles_dirs(STATIC_URL)
|
||||
|
||||
CUSTOM_THEME = os.path.join(ROOT_PATH, CUSTOM_THEME_PATH)
|
||||
|
||||
# If a custom template directory exists within our custom theme, then prepend
|
||||
# it to our first-come, first-serve TEMPLATE_DIRS
|
||||
if os.path.exists(os.path.join(CUSTOM_THEME, 'templates')):
|
||||
TEMPLATE_DIRS = \
|
||||
(os.path.join(CUSTOM_THEME, 'templates'),) + TEMPLATE_DIRS
|
||||
|
||||
# Only expose the subdirectory 'static' if it exists from a custom theme,
|
||||
# allowing other logic to live with a theme that we might not want to expose
|
||||
# statically
|
||||
if os.path.exists(os.path.join(CUSTOM_THEME, 'static')):
|
||||
CUSTOM_THEME = os.path.join(CUSTOM_THEME, 'static')
|
||||
|
||||
# Only collect and expose the default theme if the user chose to set a
|
||||
# different theme
|
||||
if DEFAULT_THEME_PATH != CUSTOM_THEME_PATH:
|
||||
STATICFILES_DIRS.append(
|
||||
('themes/default', os.path.join(ROOT_PATH, DEFAULT_THEME_PATH)),
|
||||
)
|
||||
|
||||
STATICFILES_DIRS.append(
|
||||
('custom', CUSTOM_THEME),
|
||||
AVAILABLE_THEMES, DEFAULT_THEME = theme_settings.get_available_themes(
|
||||
AVAILABLE_THEMES,
|
||||
CUSTOM_THEME_PATH,
|
||||
DEFAULT_THEME_PATH,
|
||||
DEFAULT_THEME
|
||||
)
|
||||
|
||||
# Load the subdirectory 'img' of a custom theme if it exists, thereby allowing
|
||||
# very granular theme overrides of all dashboard img files using the first-come
|
||||
# first-serve filesystem loader.
|
||||
if os.path.exists(os.path.join(CUSTOM_THEME, 'img')):
|
||||
STATICFILES_DIRS.insert(0, ('dashboard/img',
|
||||
os.path.join(CUSTOM_THEME, 'img')))
|
||||
STATICFILES_DIRS = get_staticfiles_dirs(STATIC_URL) + \
|
||||
theme_settings.get_theme_static_dirs(
|
||||
AVAILABLE_THEMES,
|
||||
THEME_COLLECTION_DIR,
|
||||
ROOT_PATH)
|
||||
|
||||
if CUSTOM_THEME_PATH is not None:
|
||||
logging.warning("CUSTOM_THEME_PATH has been deprecated. Please convert "
|
||||
"your settings to make use of AVAILABLE_THEMES.")
|
||||
|
||||
if DEFAULT_THEME_PATH is not None:
|
||||
logging.warning("DEFAULT_THEME_PATH has been deprecated. Please convert "
|
||||
"your settings to make use of AVAILABLE_THEMES.")
|
||||
|
||||
# populate HORIZON_CONFIG with auto-discovered JavaScript sources, mock files,
|
||||
# specs files and external templates.
|
||||
@ -367,13 +379,16 @@ INSTALLED_APPS[0:0] = ADD_INSTALLED_APPS
|
||||
from openstack_auth import policy
|
||||
POLICY_CHECK_FUNCTION = policy.check
|
||||
|
||||
# Add HORIZON_CONFIG to the context information for offline compression
|
||||
COMPRESS_OFFLINE_CONTEXT = {
|
||||
# This base context objects gets added to the offline context generator
|
||||
# for each theme configured.
|
||||
HORIZON_COMPRESS_OFFLINE_CONTEXT_BASE = {
|
||||
'WEBROOT': WEBROOT,
|
||||
'STATIC_URL': STATIC_URL,
|
||||
'HORIZON_CONFIG': HORIZON_CONFIG,
|
||||
'HORIZON_CONFIG': HORIZON_CONFIG
|
||||
}
|
||||
|
||||
COMPRESS_OFFLINE_CONTEXT = 'horizon.themes.offline_context'
|
||||
|
||||
if DEBUG:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
1
openstack_dashboard/static/app/_app.scss
Normal file
1
openstack_dashboard/static/app/_app.scss
Normal file
@ -0,0 +1 @@
|
||||
@import "core/core";
|
@ -1,5 +0,0 @@
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
@import "/dashboard/scss/variables";
|
||||
|
||||
@import "core/core";
|
@ -1,7 +1,3 @@
|
||||
/* This import is required for using the current theme variables as value
|
||||
to our variables */
|
||||
@import "/custom/variables";
|
||||
|
||||
/* When used with Horizon via Django, this value is set automatically from
|
||||
settings.py and is added dynamically to the namespace through
|
||||
horizon/utils/scss_filter.py */
|
||||
|
@ -1,6 +1,3 @@
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
|
||||
// Horizon Variables
|
||||
@import "variables";
|
||||
|
||||
|
@ -1,25 +1,29 @@
|
||||
{% load compress %}
|
||||
{% load themes %}
|
||||
|
||||
{% compress css %}
|
||||
<link href='{{ STATIC_URL }}horizon/lib/bootstrap_datepicker/datepicker3.css' type='text/css' media='screen' rel='stylesheet' />
|
||||
<link href='{{ STATIC_URL }}horizon/lib/rickshaw.css' type='text/css' media='screen' rel='stylesheet' />
|
||||
{% endcompress %}
|
||||
|
||||
|
||||
{% current_theme as current_theme %}
|
||||
{% theme_dir as theme_dir %}
|
||||
|
||||
{% comment %}
|
||||
We want to have separate compressed css files for horizon.scss and dashboard.scss.
|
||||
The reason for it is based on the fact that IE9 has a limit on the number of css rules
|
||||
that can be parsed in a single css file. The limit is 4095 = (4k - 1). This causes some
|
||||
css rules getting cut off if one css file to get more than 4k rules inside.
|
||||
The following 'include' is used to allow all scss files to share the same variable namespace
|
||||
and also have access to ALL styles so that we can allow @extend functionality to persist.
|
||||
|
||||
If you wish to add new scss files, it is recommended that you add them from within the
|
||||
themes/themes.scss template file.
|
||||
{% endcomment %}
|
||||
|
||||
{% load compress %}
|
||||
|
||||
{% with THEME=current_theme THEME_DIR=theme_dir %}
|
||||
{% compress css %}
|
||||
<link href='{{ STATIC_URL }}horizon/lib/bootstrap_datepicker/datepicker3.css' type='text/scss' media='screen' rel='stylesheet' />
|
||||
<link href='{{ STATIC_URL }}horizon/lib/rickshaw.css' type='text/scss' media='screen' rel='stylesheet' />
|
||||
<link href='{{ STATIC_URL }}dashboard/scss/horizon.scss' type='text/scss' media='screen' rel='stylesheet' />
|
||||
<style type="text/scss">
|
||||
{% include 'themes/themes.scss' %}
|
||||
</style>
|
||||
{% endcompress %}
|
||||
{% endwith %}
|
||||
|
||||
{% compress css %}
|
||||
<link href='{{ STATIC_URL }}app/app.scss' type='text/scss' media='screen' rel='stylesheet' />
|
||||
|
||||
{% for file in HORIZON_CONFIG.scss_files %}
|
||||
<link href='{{ STATIC_URL }}{{ file }}' type='text/scss' media='screen' rel='stylesheet'/>
|
||||
{% endfor %}
|
||||
|
||||
{% endcompress %}
|
||||
|
||||
<link rel="shortcut icon" href="{{ STATIC_URL }}dashboard/img/favicon.ico"/>
|
||||
<link rel="shortcut icon" href="{% themable_asset 'img/favicon.ico' %}"/>
|
||||
|
@ -1,5 +1,6 @@
|
||||
{% load branding %}
|
||||
{% load themes %}
|
||||
|
||||
<a class="navbar-brand" href="{% site_branding_link %}" target="_self">
|
||||
<img class="openstack-logo" src="{{ STATIC_URL }}dashboard/img/logo.png" alt="{% site_branding %}">
|
||||
<img class="openstack-logo" src="{% themable_asset 'img/logo.png' %}" alt="{% site_branding %}">
|
||||
</a>
|
||||
|
43
openstack_dashboard/templates/header/_theme_list.html
Normal file
43
openstack_dashboard/templates/header/_theme_list.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% load i18n %}
|
||||
{% load themes %}
|
||||
|
||||
|
||||
{% theme_cookie as theme_cookie %}
|
||||
<ul class="dropdown-menu theme-picker">
|
||||
<li class="dropdown-header">{% trans "Themes:" %}</li>
|
||||
{% current_theme as current_theme %}
|
||||
{% for theme in available_themes %}
|
||||
<li>
|
||||
<a data-theme="{{ theme.0 }}"
|
||||
class="theme-{{ theme.0 }} theme-picker-item {% if current_theme == theme.0 %}dropdown-selected disabled{% else %}openstack-spin{% endif %}"
|
||||
href="#"
|
||||
target="_self">
|
||||
<span class="fa fa-check dropdown-selected-icon"></span>
|
||||
<span class="dropdown-title">{{ theme.1 }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
horizon.addInitFunction(function() {
|
||||
|
||||
$(document).on('click', '.theme-picker-item', function(e) {
|
||||
var $this = $(this);
|
||||
|
||||
if($this.hasClass('disabled')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
var CookieDate = new Date;
|
||||
CookieDate.setFullYear(CookieDate.getFullYear( ) +10);
|
||||
|
||||
document.cookie = '{{ theme_cookie }}=' + $this.data('theme') + '; path=/; expires=' + CookieDate.toGMTString( ) + ';';
|
||||
document.location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
@ -1,16 +1,17 @@
|
||||
{% load i18n %}
|
||||
{% load themes %}
|
||||
|
||||
{% if not_list %}
|
||||
<div class="dropdown">
|
||||
<div class="dropdown user-menu">
|
||||
{% else %}
|
||||
<li class="dropdown">
|
||||
<li class="dropdown user-menu">
|
||||
{% endif %}
|
||||
<a data-toggle="dropdown" href="#" class="dropdown-toggle" role="button" aria-expanded="false">
|
||||
<span class="fa fa-user"></span>
|
||||
<span class="user-name">{{ request.user.username }}</span>
|
||||
<span class="fa fa-caret-down"></span>
|
||||
</a>
|
||||
<ul id="editor_list" class="dropdown-menu dropdown-menu-right">
|
||||
<ul id="editor_list" class="dropdown-menu dropdown-menu-right selection-menu">
|
||||
<li>
|
||||
<a href="{% url 'horizon:settings:user:index' %}" target="_self">
|
||||
<span class="fa fa-cog"></span>
|
||||
@ -33,6 +34,13 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% themes as available_themes %}
|
||||
{% if available_themes and available_themes|length > 1 %}
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
{% include 'header/_theme_list.html' %}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a href="{% url 'logout' %}" target="_self">
|
||||
|
15
openstack_dashboard/templates/themes/themes.scss
Normal file
15
openstack_dashboard/templates/themes/themes.scss
Normal file
@ -0,0 +1,15 @@
|
||||
// My Themes
|
||||
@import "/{{ THEME_DIR }}/{{ THEME }}/variables";
|
||||
|
||||
// Horizon
|
||||
@import "/dashboard/scss/horizon.scss";
|
||||
|
||||
// Angular
|
||||
@import "/app/app";
|
||||
|
||||
{% for file in HORIZON_CONFIG.scss_files %}
|
||||
@import '/{{ file }}';
|
||||
{% endfor %}
|
||||
|
||||
// Custom Styles
|
||||
@import "/{{ THEME_DIR }}/{{ THEME }}/styles";
|
91
openstack_dashboard/templatetags/themes.py
Normal file
91
openstack_dashboard/templatetags/themes.py
Normal file
@ -0,0 +1,91 @@
|
||||
# Copyright 2016 Hewlett Packard Enterprise Software, LLC
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import os
|
||||
from six.moves.urllib.request import pathname2url
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django import template
|
||||
|
||||
from horizon import themes as hz_themes
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def get_theme(request):
|
||||
|
||||
this_theme = hz_themes.get_default_theme()
|
||||
try:
|
||||
theme = request.COOKIES[hz_themes.get_theme_cookie_name()]
|
||||
for each_theme in hz_themes.get_themes():
|
||||
if theme == each_theme[0]:
|
||||
this_theme = each_theme[0]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return this_theme
|
||||
|
||||
|
||||
def find_asset(theme, asset):
|
||||
|
||||
theme_path = ''
|
||||
for name, label, path in hz_themes.get_themes():
|
||||
if theme == name:
|
||||
theme_path = path
|
||||
|
||||
theme_path = os.path.join(settings.ROOT_PATH, theme_path)
|
||||
|
||||
# If there is a 'static' subdir of the theme, then use
|
||||
# that as the theme's asset root path
|
||||
static_path = os.path.join(theme_path, 'static')
|
||||
if os.path.exists(static_path):
|
||||
theme_path = static_path
|
||||
|
||||
# The full path to the asset requested
|
||||
asset_path = os.path.join(theme_path, asset)
|
||||
if os.path.exists(asset_path):
|
||||
return_path = os.path.join(hz_themes.get_theme_dir(), theme, asset)
|
||||
else:
|
||||
return_path = os.path.join('dashboard', asset)
|
||||
|
||||
return staticfiles_storage.url(pathname2url(return_path))
|
||||
|
||||
|
||||
@register.assignment_tag()
|
||||
def themes():
|
||||
return hz_themes.get_themes()
|
||||
|
||||
|
||||
@register.assignment_tag()
|
||||
def theme_cookie():
|
||||
return hz_themes.get_theme_cookie_name()
|
||||
|
||||
|
||||
@register.assignment_tag()
|
||||
def theme_dir():
|
||||
return hz_themes.get_theme_dir()
|
||||
|
||||
|
||||
@register.assignment_tag(takes_context=True)
|
||||
def current_theme(context):
|
||||
return get_theme(context.request)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def themable_asset(context, asset):
|
||||
return find_asset(get_theme(context.request), asset)
|
103
openstack_dashboard/theme_settings.py
Normal file
103
openstack_dashboard/theme_settings.py
Normal file
@ -0,0 +1,103 @@
|
||||
# Copyright 2016 Hewlett Packard Enterprise Software, LLC
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
|
||||
def get_theme_static_dirs(available_themes, collection_dir, root):
|
||||
static_dirs = []
|
||||
# Collect and expose the themes that have been configured
|
||||
for theme in available_themes:
|
||||
theme_name, theme_label, theme_path = theme
|
||||
theme_url = os.path.join(collection_dir, theme_name)
|
||||
theme_path = os.path.join(root, theme_path)
|
||||
if os.path.exists(os.path.join(theme_path, 'static')):
|
||||
# Only expose the subdirectory 'static' if it exists from a custom
|
||||
# theme, allowing other logic to live with a theme that we might
|
||||
# not want to expose statically
|
||||
theme_path = os.path.join(theme_path, 'static')
|
||||
|
||||
static_dirs.append(
|
||||
(theme_url, theme_path),
|
||||
)
|
||||
|
||||
return static_dirs
|
||||
|
||||
|
||||
def get_available_themes(available_themes, custom_path, default_path,
|
||||
default_theme):
|
||||
new_theme_list = []
|
||||
# We can only support one path at a time, because of static file
|
||||
# collection.
|
||||
custom_ndx = -1
|
||||
default_ndx = -1
|
||||
default_theme_ndx = -1
|
||||
for ndx, each_theme in enumerate(available_themes):
|
||||
|
||||
# Maintain Backward Compatibility for CUSTOM_THEME_PATH
|
||||
if custom_path:
|
||||
if each_theme[2] == custom_path:
|
||||
custom_ndx = ndx
|
||||
|
||||
# Maintain Backward Compatibility for DEFAULT_THEME_PATH
|
||||
if default_path:
|
||||
if each_theme[0] == 'default':
|
||||
default_ndx = ndx
|
||||
each_theme = (
|
||||
'default',
|
||||
pgettext_lazy('Default style theme', 'Default'),
|
||||
default_path
|
||||
)
|
||||
|
||||
# Make sure that DEFAULT_THEME is configured for use
|
||||
if each_theme[0] == default_theme:
|
||||
default_theme_ndx = ndx
|
||||
|
||||
new_theme_list.append(each_theme)
|
||||
|
||||
if custom_ndx != -1:
|
||||
# If CUSTOM_THEME_PATH is set, then we should set that as the default
|
||||
# theme to make sure that upgrading Horizon doesn't jostle anyone
|
||||
default_theme = available_themes[custom_ndx][0]
|
||||
logging.warning("Your AVAILABLE_THEMES already contains your "
|
||||
"CUSTOM_THEME_PATH, therefore using configuration in "
|
||||
"AVAILABLE_THEMES for %s." % custom_path)
|
||||
|
||||
elif custom_path is not None:
|
||||
new_theme_list.append(
|
||||
('custom',
|
||||
pgettext_lazy('Custom style theme', 'Custom'),
|
||||
custom_path)
|
||||
)
|
||||
default_theme = 'custom'
|
||||
|
||||
# If 'default' isn't present at all, add it with the default_path
|
||||
if default_ndx == -1 and default_path is not None:
|
||||
new_theme_list.append(
|
||||
('default',
|
||||
pgettext_lazy('Default style theme', 'Default'),
|
||||
default_path)
|
||||
)
|
||||
|
||||
# If default is not configured, we have to set one,
|
||||
# just grab the first theme
|
||||
if default_theme_ndx == -1 and custom_ndx == -1:
|
||||
default_theme = available_themes[0][0]
|
||||
|
||||
return new_theme_list, default_theme
|
@ -1,5 +1,5 @@
|
||||
// Override the web font path ... we want to set this ourselves
|
||||
$web-font-path: "-";
|
||||
$web-font-path: $static_url + "/horizon/lib/roboto_fontface/css/roboto-fontface.css";
|
||||
$roboto-font-path: $static_url + "/horizon/lib/roboto_fontface/fonts";
|
||||
|
||||
@import "variable_customizations";
|
||||
|
@ -1,8 +1,8 @@
|
||||
{% load branding i18n %}
|
||||
{% load context_selection %}
|
||||
{% load compress %}
|
||||
{% load themes %}
|
||||
|
||||
<nav class="navbar-inverse navbar-fixed-top">
|
||||
<nav class="navbar-inverse material-header navbar-fixed-top">
|
||||
<div class="container-fluid">
|
||||
<!-- Brand and toggle get grouped for better mobile display -->
|
||||
<div class="navbar-header">
|
||||
@ -16,9 +16,8 @@
|
||||
<button class="md-hamburger-trigger">
|
||||
<span class="md-hamburger-layer md-hamburger-menu"></span>
|
||||
</button>
|
||||
{% compress js inline %}
|
||||
<script src='{{ STATIC_URL }}custom/js/material.hamburger.js'></script>
|
||||
{% endcompress %}
|
||||
{% theme_dir as theme_dir %}
|
||||
<script src='{{ STATIC_URL }}{{ theme_dir }}/material/js/material.hamburger.js'></script>
|
||||
</div>
|
||||
{% include "header/_brand.html" %}
|
||||
</div>
|
||||
|
12
releasenotes/notes/dynamic-themes-b6b02238e47b99f8.yaml
Normal file
12
releasenotes/notes/dynamic-themes-b6b02238e47b99f8.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
features:
|
||||
- Horizon can be configured to run with multiple
|
||||
themes available at run time. A new selection
|
||||
widget is available through the user menu. It
|
||||
uses a browser cookie to allow users to toggle
|
||||
between the configured themes. By default,
|
||||
Horizon is configured with the two themes
|
||||
available, 'default' and 'material'.
|
||||
deprecations:
|
||||
- The setting CUSTOM_THEME_PATH is now deprecated.
|
||||
- The setting DEFAULT_THEME_PATH is now deprecated.
|
Loading…
x
Reference in New Issue
Block a user