From c9de52d6bb31ccd41afd9183c9345606d28f415f Mon Sep 17 00:00:00 2001 From: Diana Whitten Date: Sat, 30 Jan 2016 09:42:15 -0700 Subject: [PATCH] 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 --- doc/source/topics/customizing.rst | 95 ++++++---- doc/source/topics/settings.rst | 88 ++++++++++ doc/source/topics/styling.rst | 3 +- horizon/templates/auth/_splash.html | 4 +- horizon/themes.py | 164 ++++++++++++++++++ .../theme-preview/theme-preview.scss | 2 - .../templates/theme_preview/index.html | 9 +- .../contrib/developer/theme_preview/views.py | 8 - .../admin/static/dashboard/admin/admin.scss | 3 - .../static/dashboard/identity/identity.scss | 7 - .../static/dashboard/project/project.scss | 8 - openstack_dashboard/enabled/_2000_admin.py | 4 - .../_10_set_custom_theme.py.example | 2 +- .../local/local_settings.py.example | 10 +- openstack_dashboard/settings.py | 91 ++++++---- openstack_dashboard/static/app/_app.scss | 1 + openstack_dashboard/static/app/app.scss | 5 - .../static/app/core/{core.scss => _core.scss} | 0 .../core/images/{images.scss => _images.scss} | 0 .../static/dashboard/scss/_variables.scss | 4 - .../static/dashboard/scss/horizon.scss | 3 - .../templates/_stylesheets.html | 42 +++-- .../templates/header/_brand.html | 3 +- .../templates/header/_theme_list.html | 43 +++++ .../templates/header/_user_menu.html | 14 +- .../templates/themes/themes.scss | 15 ++ openstack_dashboard/templatetags/themes.py | 91 ++++++++++ openstack_dashboard/theme_settings.py | 103 +++++++++++ .../material/static/bootstrap/_variables.scss | 2 +- .../material/templates/header/_header.html | 9 +- .../dynamic-themes-b6b02238e47b99f8.yaml | 12 ++ 31 files changed, 690 insertions(+), 155 deletions(-) create mode 100644 horizon/themes.py delete mode 100644 openstack_dashboard/dashboards/admin/static/dashboard/admin/admin.scss create mode 100644 openstack_dashboard/static/app/_app.scss delete mode 100644 openstack_dashboard/static/app/app.scss rename openstack_dashboard/static/app/core/{core.scss => _core.scss} (100%) rename openstack_dashboard/static/app/core/images/{images.scss => _images.scss} (100%) create mode 100644 openstack_dashboard/templates/header/_theme_list.html create mode 100644 openstack_dashboard/templates/themes/themes.scss create mode 100644 openstack_dashboard/templatetags/themes.py create mode 100644 openstack_dashboard/theme_settings.py create mode 100644 releasenotes/notes/dynamic-themes-b6b02238e47b99f8.yaml diff --git a/doc/source/topics/customizing.rst b/doc/source/topics/customizing.rst index 15c6a0e64b..0db67f5a4c 100644 --- a/doc/source/topics/customizing.rst +++ b/doc/source/topics/customizing.rst @@ -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 -------------------- diff --git a/doc/source/topics/settings.rst b/doc/source/topics/settings.rst index 3ec3b0896b..95774491c9 100644 --- a/doc/source/topics/settings.rst +++ b/doc/source/topics/settings.rst @@ -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`` ---------------------- diff --git a/doc/source/topics/styling.rst b/doc/source/topics/styling.rst index 335a856e93..a910ab539b 100644 --- a/doc/source/topics/styling.rst +++ b/doc/source/topics/styling.rst @@ -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. diff --git a/horizon/templates/auth/_splash.html b/horizon/templates/auth/_splash.html index a9f2412102..b89aa33c7c 100644 --- a/horizon/templates/auth/_splash.html +++ b/horizon/templates/auth/_splash.html @@ -1,3 +1,5 @@ +{% load themes %} +
- +
diff --git a/horizon/themes.py b/horizon/themes.py new file mode 100644 index 0000000000..4e6fc63650 --- /dev/null +++ b/horizon/themes.py @@ -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) diff --git a/openstack_dashboard/contrib/developer/static/dashboard/developer/theme-preview/theme-preview.scss b/openstack_dashboard/contrib/developer/static/dashboard/developer/theme-preview/theme-preview.scss index 0e6fdb8f4c..df28745df1 100644 --- a/openstack_dashboard/contrib/developer/static/dashboard/developer/theme-preview/theme-preview.scss +++ b/openstack_dashboard/contrib/developer/static/dashboard/developer/theme-preview/theme-preview.scss @@ -1,5 +1,3 @@ -@import "/custom/variables"; - themepreview { #source-button { diff --git a/openstack_dashboard/contrib/developer/theme_preview/templates/theme_preview/index.html b/openstack_dashboard/contrib/developer/theme_preview/templates/theme_preview/index.html index c9580cd1ad..0dfd4c2ef9 100644 --- a/openstack_dashboard/contrib/developer/theme_preview/templates/theme_preview/index.html +++ b/openstack_dashboard/contrib/developer/theme_preview/templates/theme_preview/index.html @@ -1,12 +1,19 @@ {% extends 'base.html' %} {% load i18n %} +{% load themes %} {% block title %} {% trans "Theme Preview" %} {% endblock %} {% block page_header %} -

{{ skin }} {{ skin_desc }}

+ {% current_theme as current_theme %} + {% themes as available_themes %} + {% for theme in available_themes %} + {% if current_theme == theme.0 %} +

{{ theme.1 }} {{ theme.2 }}

+ {% endif %} + {% endfor %} {% endblock %} {% block main %} diff --git a/openstack_dashboard/contrib/developer/theme_preview/views.py b/openstack_dashboard/contrib/developer/theme_preview/views.py index cb3aefb70b..d9dac64628 100644 --- a/openstack_dashboard/contrib/developer/theme_preview/views.py +++ b/openstack_dashboard/contrib/developer/theme_preview/views.py @@ -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 diff --git a/openstack_dashboard/dashboards/admin/static/dashboard/admin/admin.scss b/openstack_dashboard/dashboards/admin/static/dashboard/admin/admin.scss deleted file mode 100644 index 158f0f1232..0000000000 --- a/openstack_dashboard/dashboards/admin/static/dashboard/admin/admin.scss +++ /dev/null @@ -1,3 +0,0 @@ -// Custom Theme Variables -@import "/custom/variables"; -@import "/dashboard/scss/variables"; \ No newline at end of file diff --git a/openstack_dashboard/dashboards/identity/static/dashboard/identity/identity.scss b/openstack_dashboard/dashboards/identity/static/dashboard/identity/identity.scss index 2871a4540c..c18add6fdf 100644 --- a/openstack_dashboard/dashboards/identity/static/dashboard/identity/identity.scss +++ b/openstack_dashboard/dashboards/identity/static/dashboard/identity/identity.scss @@ -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"; diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/project.scss b/openstack_dashboard/dashboards/project/static/dashboard/project/project.scss index c36d312b7c..65a4bcf5b0 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/project.scss +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/project.scss @@ -1,9 +1 @@ - -// Custom Theme Variables -@import "/custom/variables"; - -@import "/dashboard/scss/variables"; @import "workflow/workflow"; - -// Custom Style Variables -@import "/custom/styles"; diff --git a/openstack_dashboard/enabled/_2000_admin.py b/openstack_dashboard/enabled/_2000_admin.py index 855cbda196..34363d770a 100644 --- a/openstack_dashboard/enabled/_2000_admin.py +++ b/openstack_dashboard/enabled/_2000_admin.py @@ -25,7 +25,3 @@ ADD_ANGULAR_MODULES = [ ] AUTO_DISCOVER_STATIC_FILES = True - -ADD_SCSS_FILES = [ - 'dashboard/admin/admin.scss' -] diff --git a/openstack_dashboard/local/local_settings.d/_10_set_custom_theme.py.example b/openstack_dashboard/local/local_settings.d/_10_set_custom_theme.py.example index 43fe4971d6..dfaa4adc5e 100644 --- a/openstack_dashboard/local/local_settings.d/_10_set_custom_theme.py.example +++ b/openstack_dashboard/local/local_settings.d/_10_set_custom_theme.py.example @@ -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')] diff --git a/openstack_dashboard/local/local_settings.py.example b/openstack_dashboard/local/local_settings.py.example index 93f7a29a8c..21abccc41f 100644 --- a/openstack_dashboard/local/local_settings.py.example +++ b/openstack_dashboard/local/local_settings.py.example @@ -428,9 +428,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, diff --git a/openstack_dashboard/settings.py b/openstack_dashboard/settings.py index c6ba045901..69ab6d0ea7 100644 --- a/openstack_dashboard/settings.py +++ b/openstack_dashboard/settings.py @@ -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) diff --git a/openstack_dashboard/static/app/_app.scss b/openstack_dashboard/static/app/_app.scss new file mode 100644 index 0000000000..e55abf5052 --- /dev/null +++ b/openstack_dashboard/static/app/_app.scss @@ -0,0 +1 @@ +@import "core/core"; diff --git a/openstack_dashboard/static/app/app.scss b/openstack_dashboard/static/app/app.scss deleted file mode 100644 index 472285521f..0000000000 --- a/openstack_dashboard/static/app/app.scss +++ /dev/null @@ -1,5 +0,0 @@ -// Custom Theme Variables -@import "/custom/variables"; -@import "/dashboard/scss/variables"; - -@import "core/core"; diff --git a/openstack_dashboard/static/app/core/core.scss b/openstack_dashboard/static/app/core/_core.scss similarity index 100% rename from openstack_dashboard/static/app/core/core.scss rename to openstack_dashboard/static/app/core/_core.scss diff --git a/openstack_dashboard/static/app/core/images/images.scss b/openstack_dashboard/static/app/core/images/_images.scss similarity index 100% rename from openstack_dashboard/static/app/core/images/images.scss rename to openstack_dashboard/static/app/core/images/_images.scss diff --git a/openstack_dashboard/static/dashboard/scss/_variables.scss b/openstack_dashboard/static/dashboard/scss/_variables.scss index cee44062b6..d7b38da128 100644 --- a/openstack_dashboard/static/dashboard/scss/_variables.scss +++ b/openstack_dashboard/static/dashboard/scss/_variables.scss @@ -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 */ diff --git a/openstack_dashboard/static/dashboard/scss/horizon.scss b/openstack_dashboard/static/dashboard/scss/horizon.scss index 0766f4cb7d..d36a0a8c55 100644 --- a/openstack_dashboard/static/dashboard/scss/horizon.scss +++ b/openstack_dashboard/static/dashboard/scss/horizon.scss @@ -1,6 +1,3 @@ -// Custom Theme Variables -@import "/custom/variables"; - // Horizon Variables @import "variables"; diff --git a/openstack_dashboard/templates/_stylesheets.html b/openstack_dashboard/templates/_stylesheets.html index 343431f0c7..61a4c3a0f4 100644 --- a/openstack_dashboard/templates/_stylesheets.html +++ b/openstack_dashboard/templates/_stylesheets.html @@ -1,25 +1,29 @@ +{% load compress %} +{% load themes %} + +{% compress css %} + + +{% 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 %} - - - + {% endcompress %} +{% endwith %} -{% compress css %} - - -{% for file in HORIZON_CONFIG.scss_files %} - -{% endfor %} - -{% endcompress %} - - + diff --git a/openstack_dashboard/templates/header/_brand.html b/openstack_dashboard/templates/header/_brand.html index ed79e43123..c3d6ed964b 100644 --- a/openstack_dashboard/templates/header/_brand.html +++ b/openstack_dashboard/templates/header/_brand.html @@ -1,5 +1,6 @@ {% load branding %} +{% load themes %} - + diff --git a/openstack_dashboard/templates/header/_theme_list.html b/openstack_dashboard/templates/header/_theme_list.html new file mode 100644 index 0000000000..42bf97a20e --- /dev/null +++ b/openstack_dashboard/templates/header/_theme_list.html @@ -0,0 +1,43 @@ +{% load i18n %} +{% load themes %} + + +{% theme_cookie as theme_cookie %} + + + \ No newline at end of file diff --git a/openstack_dashboard/templates/header/_user_menu.html b/openstack_dashboard/templates/header/_user_menu.html index ecea4a06ab..716ea9f4f7 100644 --- a/openstack_dashboard/templates/header/_user_menu.html +++ b/openstack_dashboard/templates/header/_user_menu.html @@ -1,16 +1,17 @@ {% load i18n %} +{% load themes %} {% if not_list %} -