Dynamic Themes

Horizon themes are now configurable at a user level, through the use
of cookies. The themes that can be set are configurable at a
deployment level through settings.py. Horizon can be configured to
run with multiple themes, and allow users to choose which themes
they wish to run.

Django Compressor:
In order to support dynamic themes, each theme configuration must
be pre-compiled through the Django compressor. By making use of its
built in COMPRESS_OFFLINE_CONTEXT, we now return a generator to
create each of the theme's necessary offline contexts.

Templates:
Horizon themes allowed template overrides via their 'templates'
subfolder.  In order to maintain this parity, a custom theme template
loader was created.  It is run before the other loads, and simply
looks for a Django template in the current theme (cookie driven)
before diverting to the previous template loaders.

Static Files:
Horizon themes allowed static overrides of the images in
'dashboard/img' folder.  A template tag, 'themable_asset' was created
to maintain this parity. Any asset that is wished to be made themable,
given that it is located in Horizon's 'static/dashboard' folder, can
now be made ot be themable.  By making this a template tag, this
gives the developers more granular control over what branders can
customize.

Angular and Plugins:
By far, the trickiest part of this task, Angular and Plugins are
dynamic in the files that they 'discover'.  SCSS is not flexible in
this manner at ALL.  SCSS disallows the importation of a variable
name.  To get around this, themes.scss was created as a Django
template.  This template is the top level import file for all styles
within Horizon, and therefore, allows ALL the scss files to share a
common namespace and thus, can use shared variables as well as extend
shared styles.

Other:
This change is fundamental, in that it changes the method by which
Horizon ingests its SCSS files.  Many problems existing in the
previous implementation, in an effort to make Horizon flexible, its
SCSS was made very inflexible.  This patch corrects those problems.

Change-Id: Ic48b4b5c1d1a41f1e01a8d52784c9d38d192c8f1
Implements: blueprint horizon-dynamic-theme
Closes-Bug: #1480427
This commit is contained in:
Diana Whitten 2016-01-30 09:42:15 -07:00
parent c9fdecc8e8
commit c9de52d6bb
31 changed files with 690 additions and 155 deletions

View File

@ -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 and a ``_styles.scss`` file with additional styles to load after dashboard
styles have loaded. styles have loaded.
To use a custom theme, set ``CUSTOM_THEME_PATH`` in ``local_settings.py`` to As of the Mitaka release, Horizon can be configured to run with multiple
the directory location for the theme (e.g., ``"themes/material"``). The themes available at run time. It uses a browser cookie to allow users to
path can either be relative to the ``openstack_dashboard`` directory or an toggle between the configured themes. By default, Horizon is configured
absolute path to an accessible location on the file system. The default with the two standard themes available: 'default' and 'material'.
``CUSTOM_THEME_PATH`` is ``themes/default``.
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. 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 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 Once you have made your changes you must re-generate the static files with
``./run_tests.py -m collectstatic``. ``./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` Once collected, any theme configured via ``AVAILABLE_THEMES`` is available to
and `CUSTOM_THEME_PATH` are collected during the `collectstatic` process into inherit from by importing its variables and styles from its collection
the dynamic `static` directory into the following directories:: directory. The following is an example of inheriting from the material theme::
CUSTOM_THEME_PATH: /custom @import "/themes/material/variables";
DEFAULT_THEME_PATH: /themes/default @import "/themes/material/styles";
Bootswatch
~~~~~~~~~~
.. NOTE:: 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.
However, if `DEFAULT_THEME_PATH` and `CUSTOM_THEME_PATH` are equal, then the This is due to the fact that Bootswatch is loaded as a 3rd party static asset,
only directory that will be collected into `static` is `/custom`. and therefore is automatically collected into the `static` directory in
`/horizon/lib/`. The following is an example of how to inherit from Bootswatch's
By default, `DEFAULT_THEME_PATH` is set to the 'default' theme path, therefore ``darkly`` theme::
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::
@import "/horizon/lib/bootswatch/darkly/variables"; @import "/horizon/lib/bootswatch/darkly/variables";
@import "/horizon/lib/bootswatch/darkly/bootswatch"; @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 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 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 to live at ``horizon/_sidebar.html``. You would need to duplicate that
directory structure under your templates directory, such that your override 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 The ``img`` Folder
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
If the static root of the theme folder contains an ``img`` directory, If the static root of the theme folder contains an ``img`` directory,
then all images contained within ``dashboard/img`` can be overridden by then all images that make use of the {% themable_asset %} templatetag
providing a file with the same name. can be overridden.
For a complete list of the images that can be overridden this way, see: These assets include logo.png, splash-logo.png and favicon.ico, however
``openstack_dashboard/static/dashboard/img`` overriding the SVG/GIF assets used by Heat within the `dashboard/img` folder
is not currently supported.
Customizing the Logo Customizing the Logo
-------------------- --------------------

View File

@ -422,6 +422,85 @@ This example sorts flavors by vcpus in descending order::
'reverse': True, '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:
``CUSTOM_THEME_PATH`` ``CUSTOM_THEME_PATH``
@ -429,6 +508,8 @@ This example sorts flavors by vcpus in descending order::
.. versionadded:: 2015.1(Kilo) .. versionadded:: 2015.1(Kilo)
(Deprecated)
Default: ``"themes/default"`` Default: ``"themes/default"``
This setting tells Horizon to use a directory as a custom theme. 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 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``. 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`` ``DEFAULT_THEME_PATH``
---------------------- ----------------------
.. versionadded:: 8.0.0(Liberty) .. versionadded:: 8.0.0(Liberty)
(Deprecated)
Default: ``"themes/default"`` Default: ``"themes/default"``
This setting allows Horizon to collect an additional theme during static 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 If DEFAULT_THEME_PATH is the same as CUSTOM_THEME_PATH, then collection
is skipped and /static/themes will not exist. 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`` ``DROPDOWN_MAX_ITEMS``
---------------------- ----------------------

View File

@ -120,5 +120,4 @@ A second theme is provided by default at
``openstack_dashboard/themes/material/``. When adding new SCSS to horizon, you ``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 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/. 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 This theme is now configured to run as the alternate theme within Horizon.
documentation.

View File

@ -1,3 +1,5 @@
{% load themes %}
<div class="text-center"> <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> </div>

164
horizon/themes.py Normal file
View 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)

View File

@ -1,5 +1,3 @@
@import "/custom/variables";
themepreview { themepreview {
#source-button { #source-button {

View File

@ -1,12 +1,19 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n %}
{% load themes %}
{% block title %} {% block title %}
{% trans "Theme Preview" %} {% trans "Theme Preview" %}
{% endblock %} {% endblock %}
{% block page_header %} {% 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 %} {% endblock %}
{% block main %} {% block main %}

View File

@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from horizon import views from horizon import views
@ -21,10 +20,3 @@ from horizon import views
class IndexView(views.HorizonTemplateView): class IndexView(views.HorizonTemplateView):
template_name = 'developer/theme_preview/index.html' template_name = 'developer/theme_preview/index.html'
page_title = _("Bootstrap Theme Preview") 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

View File

@ -1,3 +0,0 @@
// Custom Theme Variables
@import "/custom/variables";
@import "/dashboard/scss/variables";

View File

@ -1,9 +1,2 @@
// Custom Theme Variables
@import "/custom/variables";
@import "/dashboard/scss/variables";
@import "users/users"; @import "users/users";
@import "projects/projects"; @import "projects/projects";
// Custom Style Variables
@import "/custom/styles";

View File

@ -1,9 +1 @@
// Custom Theme Variables
@import "/custom/variables";
@import "/dashboard/scss/variables";
@import "workflow/workflow"; @import "workflow/workflow";
// Custom Style Variables
@import "/custom/styles";

View File

@ -25,7 +25,3 @@ ADD_ANGULAR_MODULES = [
] ]
AUTO_DISCOVER_STATIC_FILES = True AUTO_DISCOVER_STATIC_FILES = True
ADD_SCSS_FILES = [
'dashboard/admin/admin.scss'
]

View File

@ -1,2 +1,2 @@
# override the CUSTOM_THEME_PATH variable with this settings snippet # override the CUSTOM_THEME_PATH variable with this settings snippet
# CUSTOM_THEME_PATH="themes/material" # AVAILABLE_THEMES=[('material', 'Material', 'themes/material')]

View File

@ -428,9 +428,13 @@ TIME_ZONE = "UTC"
#TROVE_ADD_USER_PERMS = [] #TROVE_ADD_USER_PERMS = []
#TROVE_ADD_DATABASE_PERMS = [] #TROVE_ADD_DATABASE_PERMS = []
# Change this patch to the appropriate static directory containing # Change this patch to the appropriate list of tuples containing
# two files: _variables.scss and _styles.scss # a key, label and static directory containing two files:
#CUSTOM_THEME_PATH = 'themes/default' # _variables.scss and _styles.scss
#AVAILABLE_THEMES = [
# ('default', 'Default', 'themes/default'),
# ('material', 'Material', 'themes/material'),
#]
LOGGING = { LOGGING = {
'version': 1, 'version': 1,

View File

@ -21,11 +21,13 @@ import os
import sys import sys
import warnings import warnings
from django.utils.translation import pgettext_lazy
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from openstack_dashboard import exceptions from openstack_dashboard import exceptions
from openstack_dashboard.static_settings import find_static_files # noqa from openstack_dashboard.static_settings import find_static_files # noqa
from openstack_dashboard.static_settings import get_staticfiles_dirs # noqa from openstack_dashboard.static_settings import get_staticfiles_dirs # noqa
from openstack_dashboard import theme_settings
warnings.formatwarning = lambda message, category, *args, **kwargs: \ warnings.formatwarning = lambda message, category, *args, **kwargs: \
@ -105,6 +107,7 @@ MIDDLEWARE_CLASSES = (
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'horizon.middleware.HorizonMiddleware', 'horizon.middleware.HorizonMiddleware',
'horizon.themes.ThemeMiddleware',
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
) )
@ -121,6 +124,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
) )
TEMPLATE_LOADERS = ( TEMPLATE_LOADERS = (
'horizon.themes.ThemeTemplateLoader',
('django.template.loaders.cached.Loader', ( ('django.template.loaders.cached.Loader', (
'django.template.loaders.filesystem.Loader', 'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader', 'django.template.loaders.app_directories.Loader',
@ -257,10 +261,31 @@ SECURITY_GROUP_RULES = {
ADD_INSTALLED_APPS = [] ADD_INSTALLED_APPS = []
# directory for custom theme, set as default. # Deprecated Theme Settings
# It can be overridden in local_settings.py CUSTOM_THEME_PATH = None
DEFAULT_THEME_PATH = 'themes/default' DEFAULT_THEME_PATH = None
CUSTOM_THEME_PATH = DEFAULT_THEME_PATH
# '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: try:
from local.local_settings import * # noqa from local.local_settings import * # noqa
@ -298,39 +323,26 @@ if STATIC_ROOT is None:
if STATIC_URL is None: if STATIC_URL is None:
STATIC_URL = WEBROOT + 'static/' STATIC_URL = WEBROOT + 'static/'
STATICFILES_DIRS = get_staticfiles_dirs(STATIC_URL) AVAILABLE_THEMES, DEFAULT_THEME = theme_settings.get_available_themes(
AVAILABLE_THEMES,
CUSTOM_THEME = os.path.join(ROOT_PATH, CUSTOM_THEME_PATH) CUSTOM_THEME_PATH,
DEFAULT_THEME_PATH,
# If a custom template directory exists within our custom theme, then prepend DEFAULT_THEME
# 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),
) )
# Load the subdirectory 'img' of a custom theme if it exists, thereby allowing STATICFILES_DIRS = get_staticfiles_dirs(STATIC_URL) + \
# very granular theme overrides of all dashboard img files using the first-come theme_settings.get_theme_static_dirs(
# first-serve filesystem loader. AVAILABLE_THEMES,
if os.path.exists(os.path.join(CUSTOM_THEME, 'img')): THEME_COLLECTION_DIR,
STATICFILES_DIRS.insert(0, ('dashboard/img', ROOT_PATH)
os.path.join(CUSTOM_THEME, 'img')))
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, # populate HORIZON_CONFIG with auto-discovered JavaScript sources, mock files,
# specs files and external templates. # specs files and external templates.
@ -367,13 +379,16 @@ INSTALLED_APPS[0:0] = ADD_INSTALLED_APPS
from openstack_auth import policy from openstack_auth import policy
POLICY_CHECK_FUNCTION = policy.check POLICY_CHECK_FUNCTION = policy.check
# Add HORIZON_CONFIG to the context information for offline compression # This base context objects gets added to the offline context generator
COMPRESS_OFFLINE_CONTEXT = { # for each theme configured.
HORIZON_COMPRESS_OFFLINE_CONTEXT_BASE = {
'WEBROOT': WEBROOT, 'WEBROOT': WEBROOT,
'STATIC_URL': STATIC_URL, 'STATIC_URL': STATIC_URL,
'HORIZON_CONFIG': HORIZON_CONFIG, 'HORIZON_CONFIG': HORIZON_CONFIG
} }
COMPRESS_OFFLINE_CONTEXT = 'horizon.themes.offline_context'
if DEBUG: if DEBUG:
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)

View File

@ -0,0 +1 @@
@import "core/core";

View File

@ -1,5 +0,0 @@
// Custom Theme Variables
@import "/custom/variables";
@import "/dashboard/scss/variables";
@import "core/core";

View File

@ -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 /* When used with Horizon via Django, this value is set automatically from
settings.py and is added dynamically to the namespace through settings.py and is added dynamically to the namespace through
horizon/utils/scss_filter.py */ horizon/utils/scss_filter.py */

View File

@ -1,6 +1,3 @@
// Custom Theme Variables
@import "/custom/variables";
// Horizon Variables // Horizon Variables
@import "variables"; @import "variables";

View File

@ -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 %} {% comment %}
We want to have separate compressed css files for horizon.scss and dashboard.scss. The following 'include' is used to allow all scss files to share the same variable namespace
The reason for it is based on the fact that IE9 has a limit on the number of css rules and also have access to ALL styles so that we can allow @extend functionality to persist.
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. If you wish to add new scss files, it is recommended that you add them from within the
themes/themes.scss template file.
{% endcomment %} {% endcomment %}
{% load compress %} {% with THEME=current_theme THEME_DIR=theme_dir %}
{% compress css %} {% compress css %}
<link href='{{ STATIC_URL }}horizon/lib/bootstrap_datepicker/datepicker3.css' type='text/scss' media='screen' rel='stylesheet' /> <style type="text/scss">
<link href='{{ STATIC_URL }}horizon/lib/rickshaw.css' type='text/scss' media='screen' rel='stylesheet' /> {% include 'themes/themes.scss' %}
<link href='{{ STATIC_URL }}dashboard/scss/horizon.scss' type='text/scss' media='screen' rel='stylesheet' /> </style>
{% endcompress %} {% endcompress %}
{% endwith %}
{% compress css %} <link rel="shortcut icon" href="{% themable_asset 'img/favicon.ico' %}"/>
<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"/>

View File

@ -1,5 +1,6 @@
{% load branding %} {% load branding %}
{% load themes %}
<a class="navbar-brand" href="{% site_branding_link %}" target="_self"> <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> </a>

View 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>

View File

@ -1,16 +1,17 @@
{% load i18n %} {% load i18n %}
{% load themes %}
{% if not_list %} {% if not_list %}
<div class="dropdown"> <div class="dropdown user-menu">
{% else %} {% else %}
<li class="dropdown"> <li class="dropdown user-menu">
{% endif %} {% endif %}
<a data-toggle="dropdown" href="#" class="dropdown-toggle" role="button" aria-expanded="false"> <a data-toggle="dropdown" href="#" class="dropdown-toggle" role="button" aria-expanded="false">
<span class="fa fa-user"></span> <span class="fa fa-user"></span>
<span class="user-name">{{ request.user.username }}</span> <span class="user-name">{{ request.user.username }}</span>
<span class="fa fa-caret-down"></span> <span class="fa fa-caret-down"></span>
</a> </a>
<ul id="editor_list" class="dropdown-menu dropdown-menu-right"> <ul id="editor_list" class="dropdown-menu dropdown-menu-right selection-menu">
<li> <li>
<a href="{% url 'horizon:settings:user:index' %}" target="_self"> <a href="{% url 'horizon:settings:user:index' %}" target="_self">
<span class="fa fa-cog"></span> <span class="fa fa-cog"></span>
@ -33,6 +34,13 @@
</a> </a>
</li> </li>
{% endif %} {% 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 class="divider"></li>
<li> <li>
<a href="{% url 'logout' %}" target="_self"> <a href="{% url 'logout' %}" target="_self">

View 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";

View 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)

View 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

View File

@ -1,5 +1,5 @@
// Override the web font path ... we want to set this ourselves // 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"; $roboto-font-path: $static_url + "/horizon/lib/roboto_fontface/fonts";
@import "variable_customizations"; @import "variable_customizations";

View File

@ -1,8 +1,8 @@
{% load branding i18n %} {% load branding i18n %}
{% load context_selection %} {% 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"> <div class="container-fluid">
<!-- Brand and toggle get grouped for better mobile display --> <!-- Brand and toggle get grouped for better mobile display -->
<div class="navbar-header"> <div class="navbar-header">
@ -16,9 +16,8 @@
<button class="md-hamburger-trigger"> <button class="md-hamburger-trigger">
<span class="md-hamburger-layer md-hamburger-menu"></span> <span class="md-hamburger-layer md-hamburger-menu"></span>
</button> </button>
{% compress js inline %} {% theme_dir as theme_dir %}
<script src='{{ STATIC_URL }}custom/js/material.hamburger.js'></script> <script src='{{ STATIC_URL }}{{ theme_dir }}/material/js/material.hamburger.js'></script>
{% endcompress %}
</div> </div>
{% include "header/_brand.html" %} {% include "header/_brand.html" %}
</div> </div>

View 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.