Commit 21edee8d by Matt Drayer

Merge pull request #649 from edx/saleem-latif/WL-330

WL-330: Multi-Site Branding in Otto
parents e819db21 29881683
...@@ -71,7 +71,10 @@ quality: ...@@ -71,7 +71,10 @@ quality:
validate: validate_python validate_js validate: validate_python validate_js
static: theme_static:
python manage.py update_assets --skip-collect
static: theme_static
$(NODE_BIN)/r.js -o build.js $(NODE_BIN)/r.js -o build.js
python manage.py collectstatic --noinput -v0 python manage.py collectstatic --noinput -v0
python manage.py compress -v0 --force python manage.py compress -v0 --force
......
Comprehensive Theming
=====================
Any application, including Otto, can be loosely divided into two parts:
- the user interface ("how it looks"),
- and the application logic ("how it works").
For example, when considering a product in Otto,
the user interface consists of how the products are laid out on the page, how the selectors look,
how the checkout button is labelled, what sort of fonts and colors are used to display the text, and so on.
The application logic consists of how Otto adjusts product price based on the discount coupons,
and how it records that information to be displayed in the future.
Theming consists of changing the user interface without changing the application logic.
When setting up an E-Commerce website, the website operator often wants to use their own logo, modify the color scheme,
change links in the header and footer for SEO (search engine optimization) purposes, and so on.
However, although the user interface may look different, the application logic must remain the same so that Otto
continues to work properly. A well-designed theme preserves the general layout and structure of the user interface,
so that users of the website still find it familiar and easy to use.
Be careful about making sweeping changes to the user interface without warning: your users will be very confused!
.. note::
Comprehensive Theming can be disabled by setting ``ENABLE_COMPREHENSIVE_THEMING`` to ``False``.
.. code-block:: python
ENABLE_COMPREHENSIVE_THEMING = False
---------------
Theme Structure
---------------
From a technical perspective, theming consists of overriding core
* Templates,
* Static Assets and
* Sass
with themed versions of those resources. Every theme must conform to a directory structure.
This structure mirrors the Otto directory structure and looks like this
.. code-block:: text
my-theme
├── README.rst
├── static
| └── images
| | └── logo.png
| |
| └── sass
| └── partials
| └── utilities
| └── _variables.scss
|
└── templates
└── oscar
| └── dashboard
| └── index.html
└── 404.html
-----------
- Templates
-----------
Any template included in ``ecommerce/templates`` directory is themable. However, make sure not to override
class names or id values of html elements inside a template, as these are used by javascript and/or css and overriding
these could cause unwanted behavior.
---------------
- Static Assets
---------------
Any static asset included in ``ecommerce/static`` can be overridden except css files present in ``ecommerce/static/css``.
Css styles can be overridden via sass overrides explained below.
.. caution::
Theme names should be unique and no static asset and/or directory name should be same as theme's name.
Otherwise static assets would not work correctly.
------
- Sass
------
Sass overrides are a little different from static asset or template overrides.
There are two types of styles included in ``ecommerce/static/sass``:
- `base`
- `partials`
.. WARNING::
Styles present in ``ecommerce/static/sass/base`` should not be overridden as overriding these
could result in an unexpected behavior.
Any styles included in ``ecommerce/static/sass/partials`` can be overridden.
Styles included in this directory contain variable definitions that are used by main sass files. This is the best place
if you want to update things like header/footer background, fonts etc.
----------------
Enabling a Theme
----------------
To enable a theme, you must first install your theme onto the same server that is running Otto.
If you are using devstack or fullstack to run Otto, you must be sure that the theme is present on the Vagrant virtual machine.
It is up to you where to install the theme on the server, but a good default location is ``/edx/app/ecommerce/ecommerce/themes``.
.. note::
All themes must reside in the same physical directory.
In order for Otto to use the installed themes, you must specify the location of the theme directory in
Django settings by setting COMPREHENSIVE_THEME_DIR in your settings file:
.. code-block:: python
COMPREHENSIVE_THEME_DIR = "/edx/app/ecommerce/ecommerce/themes"
Where ``/edx/app/ecommerce/ecommerce/themes`` is the path to where you have installed the
themes on your server.
After installing a theme, it is associated with sites by adding appropriate entries to the following tables
- ``Site``
- ``Site Themes``
for local devstack, if Otto server is running at ``localhost:8002`` you can enable a ``my-theme`` by
- Adding a new site with domain ``localhost:8002`` and name "Otto My Theme"
- and a site theme with Theme dir name ``my-theme`` and selecting ``localhost:8002`` from site dropdown.
Otto server can now be started, and ``my-theme`` should be applied now. If you have overridden sass styles and you are not
seeing those overrides then you need to compile sass files as discussed in `Compiling Theme Sass`_.
-----------------
Disabling a Theme
-----------------
Theme can be disabled by removing its corresponding ``Site Theme`` entry using django admin.
--------------------
Compiling Theme Sass
--------------------
Management command ``update_assets`` can be used for compiling and collecting themed sass.
.. code-block:: yaml
python manage.py update_assets
``update_assets`` accepts the following optional arguments
:--settings: settings file to use, ``default: ecommerce.settings.devstack``
.. code-block:: Bash
python manage.py update_assets --settings=ecommerce.settings.production
:--themes: Space separated list of themes to compile sass for. 'all' for all themes,
'no' to skip sass compilation for themes, ``default: 'all'``
.. code-block:: Bash
# compile sass for all themes
python manage.py update_assets --theme=all
# compile sass for only given themes, useful for situations if you have installed a new theme
# and want to compile sass for just this theme
python manage.py update_assets --themes my-theme second-theme third-theme
# skip sass compilation for themes, useful for testing changes to system
# sass, keeping theme styles unchanged
python manage.py update_assets --theme=no
:--output-style: Coding style for compiled css files. Possible options are ``nested``, ``expanded``,
``compact`` and ``compressed``. ``default: 'nested'``
.. code-block:: Bash
python manage.py update_assets --output-style='compressed'
:--skip-system: This flag disables system sass compilation.
.. code-block:: Bash
# useful in cases where you have updated theme sass and system sass is unchanged.
python manage.py update_assets --skip-system
:--enable-source-comments: This flag enables source comments in generated css files
.. code-block:: Bash
python manage.py update_assets --enable-source-comments
:--skip-collect: This flag can be used to skip collectstatic call after sass compilation
.. code-block:: Bash
# useful if you just want to compile sass, and collectstatic would later be called, may be by a script
python manage.py update_assets --skip-collect
---------------
Troubleshooting
---------------
If you have gone through the above procedure and you are not seeing theme overrides, you need to make sure
- ``COMPREHENSIVE_THEME_DIR`` must be path for the directory containing all themes e.g. if your theme is
``/edx/app/ecommerce/ecommerce/themes/my-theme`` then correct value for ``COMPREHENSIVE_THEME_DIR`` is
``/edx/app/ecommerce/ecommerce/themes``.
- ``domain`` name for site is the name users will put in the browser to access the site, it also includes port number
e.g. if Otto is running on ``localhost:8002`` then domain should be ``localhost:8002``
- Theme dir name is the name of the directory of you theme, for our ongoing example ``my-theme``
is the correct theme dir name.
\ No newline at end of file
...@@ -111,17 +111,27 @@ STATICFILES_DIRS = ( ...@@ -111,17 +111,27 @@ STATICFILES_DIRS = (
) )
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
# ThemeFilesFinder looks for static assets inside theme directories. It presents static assets according to the
# current theme. More details on ThemeFilesFinder can be seen at /ecommerce/theming/__init__.py
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'ecommerce.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'compressor.finders.CompressorFinder', 'compressor.finders.CompressorFinder',
) )
# ThemeStorage stores and retrieves files with theming in mind.
# More details on ThemeStorage can be seen at /ecommerce/theming/__init__.py
STATICFILES_STORAGE = "ecommerce.theming.storage.ThemeStorage"
COMPRESS_PRECOMPILERS = ( COMPRESS_PRECOMPILERS = (
('text/x-scss', 'django_libsass.SassCompiler'), ('text/x-scss', 'django_libsass.SassCompiler'),
) )
COMPRESS_CSS_FILTERS = ['compressor.filters.css_default.CssAbsoluteFilter'] COMPRESS_CSS_FILTERS = ['compressor.filters.css_default.CssAbsoluteFilter']
COMPRESS_OFFLINE_CONTEXT = 'ecommerce.theming.compressor.offline_context'
# END STATIC FILE CONFIGURATION # END STATIC FILE CONFIGURATION
...@@ -152,7 +162,6 @@ FIXTURE_DIRS = ( ...@@ -152,7 +162,6 @@ FIXTURE_DIRS = (
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'DIRS': ( 'DIRS': (
normpath(join(DJANGO_ROOT, 'templates')), normpath(join(DJANGO_ROOT, 'templates')),
# Templates which override default Oscar templates # Templates which override default Oscar templates
...@@ -160,6 +169,12 @@ TEMPLATES = [ ...@@ -160,6 +169,12 @@ TEMPLATES = [
OSCAR_MAIN_TEMPLATE_DIR, OSCAR_MAIN_TEMPLATE_DIR,
), ),
'OPTIONS': { 'OPTIONS': {
'loaders': [
# ThemeTemplateLoader should come before any other loader to give theme templates
# priority over system templates
'ecommerce.theming.template_loaders.ThemeTemplateLoader',
'django.template.loaders.app_directories.Loader',
],
'context_processors': ( 'context_processors': (
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.template.context_processors.debug', 'django.template.context_processors.debug',
...@@ -246,6 +261,9 @@ LOCAL_APPS = [ ...@@ -246,6 +261,9 @@ LOCAL_APPS = [
'ecommerce.core', 'ecommerce.core',
'ecommerce.courses', 'ecommerce.courses',
'ecommerce.invoice', 'ecommerce.invoice',
# Theming app for customizing visual and behavioral attributes of a site
'ecommerce.theming',
] ]
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
...@@ -476,3 +494,20 @@ RECEIPT_PAGE_PATH = '/commerce/checkout/receipt/' ...@@ -476,3 +494,20 @@ RECEIPT_PAGE_PATH = '/commerce/checkout/receipt/'
# Black-listed course modes not allowed to create coupons with # Black-listed course modes not allowed to create coupons with
BLACK_LIST_COUPON_COURSE_MODES = [u'audit', u'honor'] BLACK_LIST_COUPON_COURSE_MODES = [u'audit', u'honor']
# Theme settings
# enable or disbale comprehensive theming
ENABLE_COMPREHENSIVE_THEMING = True
# name for waffle switch to use for disabling theming on runtime.
# Note: management command ignore this switch
DISABLE_THEMING_ON_RUNTIME_SWITCH = "disable_theming_on_runtime"
# Directory that contains all themes
COMPREHENSIVE_THEME_DIR = DJANGO_ROOT + "/themes"
# Cache time out for theme templates and related assets
THEME_CACHE_TIMEOUT = 30 * 60
# End Theme settings
...@@ -126,3 +126,6 @@ CELERY_ALWAYS_EAGER = True ...@@ -126,3 +126,6 @@ CELERY_ALWAYS_EAGER = True
# Use production settings for asset compression so that asset compilation can be tested on the CI server. # Use production settings for asset compression so that asset compilation can be tested on the CI server.
COMPRESS_ENABLED = True COMPRESS_ENABLED = True
COMPRESS_OFFLINE = True COMPRESS_OFFLINE = True
# Comprehensive theme settings for testing environment
COMPREHENSIVE_THEME_DIR = DJANGO_ROOT + "/tests/themes"
...@@ -5,8 +5,7 @@ $brand-logo-width: 72px !default; ...@@ -5,8 +5,7 @@ $brand-logo-width: 72px !default;
$brand-logo-height: 48px !default; $brand-logo-height: 48px !default;
// utilities // utilities
// -------------------- @import "utilities/mixins";
@import "../utilities/mixins";
// overrides // overrides
// -------------------- // --------------------
......
// utilities // utilities
// -------------------- // --------------------
@import '../utilities/developer'; // developer-friendly file: add rough/WIP styling that needs UI triage @import 'utilities/developer'; // developer-friendly file: add rough/WIP styling that needs UI triage
@import '../utilities/variables'; @import 'utilities/variables';
@import '../utilities/mixins'; @import 'utilities/mixins';
// vendor // vendor
// -------------------- // --------------------
...@@ -22,14 +22,16 @@ ...@@ -22,14 +22,16 @@
// components // components
// -------------------- // --------------------
@import '../components/buttons'; @import 'components/buttons';
@import '../components/navbar'; @import 'components/navbar';
@import '../components/footer'; @import 'components/footer';
// views // views
// -------------------- // --------------------
@import '../views/basket'; @import 'views/basket';
@import '../views/credit'; @import 'views/credit';
@import '../views/course_admin'; @import 'views/course_admin';
@import '../views/coupon_admin'; @import 'views/coupon_admin';
@import '../views/coupon_offer'; @import 'views/coupon_offer';
@import "default";
$brand-logo-path: '/static/images/edx-logo.png';
$brand-logo-width: 93px;
$brand-logo-height: 47px;
/* We only need to update the logo. The variables above will override those in the Open edX theme (which will handle
defining the position of the logo). */
@import "default";
...@@ -13,11 +13,11 @@ ...@@ -13,11 +13,11 @@
<title>{% block title %}{% endblock title %}</title> <title>{% block title %}{% endblock title %}</title>
{% compress css %} {% compress css %}
<link rel="stylesheet" href="{% static 'sass/base/main.scss' %}" type="text/x-scss"> {% if main_css %}
{% captureas theme_scss %} <link rel="stylesheet" href="{{ main_css }}" type="text/x-scss">
{% settings_value 'THEME_SCSS' %} {% else %}
{% endcaptureas %} <link rel="stylesheet" href="{% static 'css/base/main.css' %}" type="text/x-scss">
<link rel="stylesheet" href="{% static theme_scss %}" type="text/x-scss"> {% endif %}
{% endcompress %} {% endcompress %}
{% compress css %} {% compress css %}
......
...@@ -8,14 +8,21 @@ ...@@ -8,14 +8,21 @@
{% block style %} {% block style %}
{{ block.super }} {{ block.super }}
{% compress css %} {% compress css %}
<link rel="stylesheet" href="{% static 'sass/base/main.scss' %}" type="text/x-scss"> {% if main_css %}
{% captureas theme_scss %} <link rel="stylesheet" href="{{ main_css }}" type="text/x-scss">
{% settings_value 'THEME_SCSS' %} {% else %}
{% endcaptureas %} <link rel="stylesheet" href="{% static 'css/base/main.css' %}" type="text/x-scss">
<link rel="stylesheet" href="{% static theme_scss %}" type="text/x-scss"> {% endif %}
<link rel="stylesheet" href="{% static 'sass/edx-swagger.scss' %}" type="text/x-scss">
{% if swagger_css %}
<link rel="stylesheet" href="{{ swagger_css }}" type="text/x-scss">
{% else %}
<link rel="stylesheet" href="{% static 'css/base/edx-swagger.css' %}" type="text/x-scss">
{% endif %}
{% endcompress %} {% endcompress %}
{% endblock %} {% endblock %}
......
# this file is to add conf/local dir to git, currently there is no file to ignore.
@import "ecommerce/static/sass/base/base";
// This file is here for testing only
@import 'ecommerce/static/sass/partials/utilities/variables';
// Theme Overrides
$body-bg: darken(#0000fa, 2%);
$navbar-default-bg: #0000fa;
$container-bg: #0000fa;
{% extends "edx/base.html" %}
{% block title %}Test Theme 2{% endblock title %}
<!-- This template is for demonstration and testing purposes only and should not be used in production -->
{% block content %}
<div>This is second Test Theme.</div>
{% endblock %}
# this file is to add conf/local dir to git, currently there is no file to ignore.
@import 'ecommerce/static/sass/partials/utilities/variables';
// Theme Overrides
$body-bg: darken(#00fa00, 2%);
$navbar-default-bg: #00fa00;
$container-bg: #00fa00;
{% extends "edx/base.html" %}
{% block title %}Test Theme 1{% endblock title %}
<!-- This template is for demonstration and testing purposes only and should not be used in production -->
{% block content %}
<div>This is a Test Theme.</div>
{% endblock %}
@import 'ecommerce/static/sass/partials/utilities/variables';
// Theme Overrides
$body-bg: darken(#ff0000, 2%);
$navbar-default-bg: #ff0000;
$container-bg: #ff0000;
{% extends "edx/base.html" %}
{% block title %}Red Theme Bootstrap Demo{% endblock title %}
<!-- This template is for demonstration and testing purposes only and should not be used in production -->
{% block content %}
<div>Red Theme Demo</div>
{% endblock %}
{% extends "edx/base.html" %}
{% block title %}Red Theme Demo{% endblock title %}
<!-- This template is for demonstration and testing purposes only and should not be used in production -->
{% block content %}
<div>Red Theme Demo</div>
{% endblock %}
"""
This app contains theming and branding logic for ecommerce. It contains necessary components, overrides and helpers
needed for the theming to work properly.
Components:
Template Loaders (ecommerce.theming.template_loaders.ThemeTemplateLoader):
Theming aware template loaders, this loader will first look in template directories of current theme and then
it will look at system template dirs.
ThemeFilesFinder looks for static assets inside theme directories. It creates separate storage for each theme.
Static Files Finders (ecommerce.theming.finders.ThemeFilesFinder):
Theming aware Static files finder.
During collectstatic run it utilizes all of these storages to fetch static assets for each theme.
Since, storage saves each theme asset in its own sub directory
(i.e. {STATIC_ROOT}/red-theme/images/default-logo.png) and also creates asset url with theme name
prefixed (i.e. /static/red-theme/images/default-logo.png), during post-processing and development mode,
finder will know which theme asset is being accessed (judging from the prefix), and will use the
corresponding storage to access the file.
If prefix is not a theme name (we know all theme names via get_themes helper), ThemeFilesFinder will know
that a system asset is being accessed.
Static Files Storage (ecommerce.theming.storage.ThemeStorage):
Theming aware static storage.
During collectstatic run it will take all themed assets and place theme in theme subdirectory (e.g. red-theme)
inside static directory. All system assets are saved normally. e.g. 'red-theme' assets will be stored in
'{STATIC_ROOT}/red-theme/' and system assets will be stored in '{STATIC_ROOT}/'.
While serving static assets it prefixes asset url with theme name (e.g. 'red-theme'), only if assets
is overridden by theme. e.g. if red-theme provides 'images/default-logo.png' then corresponding url
will be "/static/red-theme/images/default-logo.png" and if red-theme does not provide 'images/default-logo.png'
then corresponding url will be "/static/images/default-logo.png".
Models:
Site theme (ecommerce.theming.models.SiteTheme):
Site theme model to store theme info for a given site.
Fields:
site (ForeignKey): Foreign Key field pointing to django Site model
theme_dir_name (CharField): Contains directory name for any site's theme (e.g. 'red-theme')
"""
default_app_config = "ecommerce.theming.apps.ThemeAppConfig"
"""
Django admin page for theming models
"""
from django.contrib import admin
from .models import (
SiteTheme,
)
class SiteThemeAdmin(admin.ModelAdmin):
"""
Admin interface for the SiteTheme object.
"""
list_display = ('site', 'theme_dir_name')
search_fields = ('site__domain', 'theme_dir_name')
class Meta(object):
"""
Meta class for SiteTheme admin model
"""
model = SiteTheme
admin.site.register(SiteTheme, SiteThemeAdmin)
"""
Module for code that should run during application startup
"""
from django.apps import AppConfig
from ecommerce.theming.core import enable_theming
from ecommerce.theming.helpers import get_base_themes_dir, is_comprehensive_theming_enabled
class ThemeAppConfig(AppConfig):
"""
App Configurations for Theming.
"""
name = 'ecommerce.theming'
verbose_name = 'Theming'
def ready(self):
"""
startup run method, this method is called after the application has successfully initialized.
Anything that needs to executed once (and only once) the theming app starts can be placed here.
"""
if is_comprehensive_theming_enabled():
# proceed only if comprehensive theming in enabled
enable_theming(
themes_dir=get_base_themes_dir(),
)
"""
This file contains django compressor related functions.
"""
from ecommerce.theming.helpers import get_themes
from ecommerce.theming.storage import ThemeStorage
def offline_context():
"""
offline context for compress management command, offline_context function iterates
through all applied themes and returns a separate context for each theme.
"""
for theme in get_themes():
main_css = ThemeStorage(prefix=theme.theme_dir).url("css/base/main.css")
swagger_css = ThemeStorage(prefix=theme.theme_dir).url("css/base/edx-swagger.css")
yield {
'main_css': main_css,
'swagger_css': swagger_css,
}
yield {
'main_css': ThemeStorage().url("css/base/main.css"),
'swagger_css': ThemeStorage().url("css/base/edx-swagger.css"),
}
"""
Core logic for Comprehensive Theming.
"""
from django.conf import settings
from path import Path
from ecommerce.theming.helpers import get_themes
def enable_theming(themes_dir):
"""
Add directories and relevant paths to settings for comprehensive theming.
Args:
themes_dir (str): path to base theme directory
"""
if isinstance(themes_dir, basestring):
themes_dir = Path(themes_dir)
for theme in get_themes(themes_dir):
locale_dir = themes_dir / theme.theme_dir / "conf" / "locale"
if locale_dir.isdir():
settings.LOCALE_PATHS = (locale_dir, ) + settings.LOCALE_PATHS
"""
Static file finders for Django.
https://docs.djangoproject.com/en/1.8/ref/settings/#std:setting-STATICFILES_FINDERS
Yes, this interface is private and undocumented, but we need to access it anyway.
In order to deploy Open edX in production, it's important to be able to collect
and process static assets: images, CSS, JS, fonts, etc. Django's collectstatic
system is the accepted way to do that in Django-based projects, but that system
doesn't handle every kind of collection and processing that web developers need.
Other open source projects like `Django-Pipeline`_ and `Django-Require`_ hook
into Django's collectstatic system to provide features like minification,
compression, Sass pre-processing, and require.js optimization for assets before
they are pushed to production. To make sure that themed assets are collected
and served by the system (in addition to core assets), we need to extend this
interface, as well.
.. _Django-Pipeline: http://django-pipeline.readthedocs.org/
.. _Django-Require: https://github.com/etianen/django-require
"""
import os
from collections import OrderedDict
from django.contrib.staticfiles import utils
from django.contrib.staticfiles.finders import BaseFinder
from django.utils import six
from ecommerce.theming.helpers import get_themes
from ecommerce.theming.storage import ThemeStorage
class ThemeFilesFinder(BaseFinder):
"""
A static files finder that looks in the directory of each theme as
specified in the source_dir attribute.
"""
storage_class = ThemeStorage
source_dir = 'static'
def __init__(self, *args, **kwargs):
# The list of themes that are handled
self.themes = []
# Mapping of theme names to storage instances
self.storages = OrderedDict()
themes = get_themes()
for theme in themes:
theme_storage = self.storage_class(
os.path.join(theme.path, self.source_dir),
prefix=theme.theme_dir,
)
self.storages[theme.theme_dir] = theme_storage
if theme.theme_dir not in self.themes:
self.themes.append(theme.theme_dir)
super(ThemeFilesFinder, self).__init__(*args, **kwargs)
def list(self, ignore_patterns):
"""
List all files in all theme storages.
"""
for storage in six.itervalues(self.storages):
if storage.exists(''): # check if storage location exists
for path in utils.get_files(storage, ignore_patterns):
yield path, storage
def find(self, path, all=False): # pylint: disable=redefined-builtin
"""
Looks for files in the theme directories.
"""
matches = []
theme_dir = path.split("/", 1)[0]
themes = {t.theme_dir: t for t in get_themes()}
# if path is prefixed by theme name then search in the corresponding storage other wise search all storages.
if theme_dir in themes:
theme = themes[theme_dir]
path = "/".join(path.split("/")[1:])
match = self.find_in_theme(theme.theme_dir, path)
if match:
if not all:
return match
matches.append(match)
return matches
def find_in_theme(self, theme, path):
"""
Find a requested static file in an theme's static locations.
"""
storage = self.storages.get(theme, None)
if storage:
# only try to find a file if the source dir actually exists
if storage.exists(path):
matched_path = storage.path(path)
if matched_path:
return matched_path
"""
Helpers for accessing comprehensive theming related variables.
"""
import os
from django.conf import settings, ImproperlyConfigured
from django.core.cache import cache
import waffle
from path import Path
from threadlocals.threadlocals import get_current_request
def get_current_site():
"""
Return current site.
Returns:
(django.contrib.sites.models.Site): current site
"""
request = get_current_request()
if not request:
return None
return getattr(request, 'site', None)
def is_comprehensive_theming_enabled():
"""
Returns boolean indicating whether comprehensive theming functionality is enabled or disabled.
Example:
>> is_comprehensive_theming_enabled()
True
Returns:
(bool): True if comprehensive theming is enabled else False
"""
if not settings.ENABLE_COMPREHENSIVE_THEMING:
# Return False if theming is disabled
return False
# return False if theming is disabled on runtime and function is called during request processing
if bool(get_current_request()):
# check if theming is disabled on runtime
if waffle.switch_is_active(settings.DISABLE_THEMING_ON_RUNTIME_SWITCH):
# function called in request processing and theming is disabled on runtime
return False
# Theming is enabled
return True
def get_site_theme_cache_key(site):
"""
Return cache key for the given site.
Example:
>> site = Site(domain='red-theme.org', name='Red Theme')
>> get_site_theme_cache_key(site)
'theming.site.red-theme.org'
Parameters:
site (django.contrib.sites.models.Site): site where key needs to generated
Returns:
(str): a key to be used as cache key
"""
cache_key = "theming.site.{domain}".format(
domain=site.domain
)
return cache_key
def cache_site_theme_dir(site, theme_dir):
"""
Cache site's theme directory.
Example:
>> site = Site(domain='red-theme.org', name='Red Theme')
>> cache_site_theme_dir(site, 'red-theme')
Parameters:
site (django.contrib.sites.models.Site): site for to cache
theme_dir (str): theme directory for the given site
"""
cache.set(get_site_theme_cache_key(site), theme_dir, settings.THEME_CACHE_TIMEOUT)
def get_current_theme_template_dirs():
"""
Returns template directories for the current theme.
Example:
>> get_current_theme_template_dirs()
[
'/edx/app/ecommerce/ecommerce/themes/red-theme/templates/',
'/edx/app/ecommerce/ecommerce/themes/red-theme/templates/oscar/',
]
Returns:
(list): list of directories containing theme templates.
"""
site_theme_dir = get_theme_dir()
if not site_theme_dir:
return None
template_paths = [
site_theme_dir / 'templates',
site_theme_dir / 'templates' / 'oscar',
]
return template_paths
def get_all_theme_template_dirs():
"""
Returns template directories for all the themes.
Example:
>> get_all_theme_template_dirs()
[
'/edx/app/ecommerce/ecommerce/themes/red-theme/templates/',
'/edx/app/ecommerce/ecommerce/themes/red-theme/templates/oscar/',
]
Returns:
(list): list of directories containing theme templates.
"""
themes = get_themes()
template_paths = list()
for theme in themes:
template_paths.extend(
[
theme.path / 'templates',
theme.path / 'templates' / 'oscar',
],
)
return template_paths
def get_theme_dir():
"""
Return absolute directory for the current theme.
Example:
>> get_theme_dir()
'/edx/app/ecommerce/ecommerce/themes/red-theme/'
Returns:
(Path): absolute directory for the current theme
"""
site = get_current_site()
if not is_comprehensive_theming_enabled():
return None
site_theme = site and site.themes.first()
theme_dir = getattr(site_theme, "theme_dir_name") if site_theme else None
if theme_dir:
themes_dir = get_base_themes_dir()
return Path(themes_dir) / theme_dir
else:
return None
def get_base_themes_dir():
"""
Return base directory that contains all the themes.
Raises:
ImproperlyConfigured - exception is raised if
1 - COMPREHENSIVE_THEME_DIR is not a string
2 - COMPREHENSIVE_THEME_DIR is not an absolute path
3 - path specified by COMPREHENSIVE_THEME_DIR does not exist
Example:
>> get_base_themes_dir()
'/edx/app/ecommerce/ecommerce/themes'
Returns:
(Path): Base theme directory path
"""
themes_dir = settings.COMPREHENSIVE_THEME_DIR
if not isinstance(themes_dir, basestring):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be a string.")
if not themes_dir.startswith("/"):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be an absolute path to themes dir.")
if not os.path.isdir(themes_dir):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be a valid path.")
return Path(themes_dir)
def get_current_site_theme_dir():
"""
Return theme directory for the current site.
Example:
>> get_current_site_theme_dir()
'red-theme'
Returns:
(str): theme directory for current site
"""
site = get_current_site()
if not site:
return None
site_theme_dir = cache.get(get_site_theme_cache_key(site))
# if site theme dir is not in cache and comprehensive theming is enabled then pull it from db.
if not site_theme_dir and is_comprehensive_theming_enabled():
site_theme = site.themes.first() # pylint: disable=no-member
if site_theme:
site_theme_dir = site_theme.theme_dir_name
cache_site_theme_dir(site, site_theme_dir)
return site_theme_dir
def get_themes(themes_dir=None):
"""
get a list of all themes known to the system.
Args:
themes_dir (str): (Optional) Path to themes base directory
Returns:
list of themes known to the system.
"""
if not is_comprehensive_theming_enabled():
return []
themes_dir = Path(themes_dir) if themes_dir else get_base_themes_dir()
# pick only directories and discard files in themes directory
theme_names = []
if themes_dir:
theme_names = [_dir for _dir in os.listdir(themes_dir) if is_theme_dir(themes_dir / _dir)]
return [Theme(name, name) for name in theme_names]
def is_theme_dir(_dir):
"""
Returns true if given dir contains theme overrides.
A theme dir must have subdirectory 'static' or 'templates' or both.
Args:
_dir: directory path to check for a theme
Returns:
Returns true if given dir is a theme directory.
"""
theme_sub_directories = {'static', 'templates'}
return bool(os.path.isdir(_dir) and theme_sub_directories.intersection(os.listdir(_dir)))
class Theme(object):
"""
class to encapsulate theme related information.
"""
name = ''
theme_dir = ''
def __init__(self, name='', theme_dir=''):
"""
init method for Theme
Args:
name: name if the theme
theme_dir: directory name of the theme
"""
self.name = name
self.theme_dir = theme_dir
def __eq__(self, other):
"""
Returns True if given theme is same as the self
Args:
other: Theme object to compare with self
Returns:
(bool) True if two themes are the same else False
"""
return (self.theme_dir, self.path) == (other.theme_dir, other.path)
def __hash__(self):
return hash((self.theme_dir, self.path))
def __unicode__(self):
return u"<Theme: {name} at '{path}'>".format(name=self.name, path=self.path)
def __repr__(self):
return self.__unicode__()
@property
def path(self):
return Path(get_base_themes_dir()) / self.theme_dir
"""
Management commands related to Comprehensive Theming.
"""
"""
Managements for asset compilation and collection.
"""
from __future__ import unicode_literals
import logging
import datetime
from django.conf import settings
from django.core.management import BaseCommand, CommandError
from django.core.management import call_command
import sass
from path import Path
from ecommerce.theming.helpers import get_themes, get_base_themes_dir, is_comprehensive_theming_enabled
logger = logging.getLogger(__name__)
SYSTEM_SASS_PATHS = [
# to resolve @import, we need to first look in 'sass/partials' then 'sass/base' and finally in "sass" dirs
# "sass" dir does not contain any scss files yet, but can be used to place scss files that can not be overridden
# by themes and contain only variable definitions (meaning, do not need to be compiled into css).
Path("ecommerce/static/sass/partials"),
Path("ecommerce/static/sass/base"),
Path("ecommerce/static/sass"),
]
class Command(BaseCommand):
"""
Compile and collect assets.
"""
help = 'Compile and collect assets'
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
logger.addHandler(ch)
def add_arguments(self, parser):
"""
Add arguments for update_assets command.
Args:
parser (django.core.management.base.CommandParser): parsed for parsing command line arguments.
"""
# Named (optional) arguments
parser.add_argument(
'--themes',
type=str,
nargs='+',
default=["all"],
help="List of themes whose sass need to compiled. Or 'no'/'all' to compile for no/all themes.",
)
parser.add_argument(
'--output-style',
type=str,
dest='output_style',
default='nested',
help='Coding style for compiled sass (default="nested").',
)
parser.add_argument(
'--skip-system',
dest='system',
action='store_false',
default=True,
help='Skip system sass compilation.',
)
parser.add_argument(
'--enable-source-comments',
dest='source_comments',
action='store_true',
default=False,
help="Add source comments in compiled sass.",
)
parser.add_argument(
'--skip-collect',
dest='collect',
action='store_false',
default=True,
help="Skip collection of static assets.",
)
@staticmethod
def parse_arguments(*args, **options): # pylint: disable=unused-argument
"""
Parse and validate arguments for update_assets command.
Args:
*args: Positional arguments passed to the update_assets command
**options: optional arguments passed to the update_assets command
Returns:
A tuple containing parsed values for themes, system, source comments and output style.
1. themes (list): list of Theme objects
2. system (bool): True if system sass need to be compiled, False otherwise
3. source_comments (bool): True if source comments need to be included in output, False otherwise
4. output_style (str): Coding style for compiled css files.
"""
given_themes = options.get("themes", ["all"])
output_style = options.get("output_style", "nested")
system = options.get("system", True)
source_comments = options.get("source_comments", False)
collect = options.get("collect", True)
available_themes = {t.theme_dir: t for t in get_themes()}
if 'no' in given_themes or 'all' in given_themes:
# Raise error if 'all' or 'no' is present and theme names are also given.
if len(given_themes) > 1:
raise CommandError("Invalid themes value, It must either be 'all' or 'no' or list of themes.")
# Raise error if any of the given theme name is invalid
# (theme name would be invalid if it does not exist in themes directory)
elif (not set(given_themes).issubset(available_themes.keys())) and is_comprehensive_theming_enabled():
raise CommandError(
"Given themes '{invalid_themes}' do not exist inside themes directory '{themes_dir}'".format(
invalid_themes=", ".join(set(given_themes) - set(available_themes.keys())),
themes_dir=get_base_themes_dir(),
),
)
if "all" in given_themes:
themes = get_themes()
elif "no" in given_themes:
themes = []
else:
# convert theme names to Theme objects
themes = [available_themes.get(theme) for theme in given_themes]
return themes, system, source_comments, output_style, collect
def handle(self, *args, **options):
"""
Handle update_assets command.
"""
logger.info("Sass compilation started.")
info = []
themes, system, source_comments, output_style, collect = self.parse_arguments(*args, **options)
if not is_comprehensive_theming_enabled():
themes = []
logger.info("Skipping theme sass compilation as theming is disabled.")
for sass_dir in get_sass_directories(themes, system):
result = compile_sass(
sass_source_dir=sass_dir['sass_source_dir'],
css_destination_dir=sass_dir['css_destination_dir'],
lookup_paths=sass_dir['lookup_paths'],
output_style=output_style,
source_comments=source_comments,
)
info.append(result)
logger.info("Sass compilation completed.")
for sass_dir, css_dir, duration in info:
logger.info(">> %s -> %s in %ss", sass_dir, css_dir, duration)
logger.info("\n")
if collect and not settings.DEBUG:
# Collect static assets
collect_assets()
def get_sass_directories(themes, system=True):
"""
Get sass directories for given themes and system.
Args:
themes (list): list of all the themes for whom to fetch sass directories
system (bool): boolean showing whether to get sass directories for the system or not.
Returns:
List of all sass directories that need to be compiled.
"""
applicable_dirs = list()
if system:
applicable_dirs.append({
"sass_source_dir": Path("ecommerce/static/sass/base"),
"css_destination_dir": Path("ecommerce/static/css/base"),
"lookup_paths": SYSTEM_SASS_PATHS,
})
applicable_dirs.extend(get_theme_sass_directories(themes))
return applicable_dirs
def get_theme_sass_directories(themes):
"""
Get sass directories for given themes and system.
Args:
themes (list): list of all the themes for whom to fetch sass directories
Returns:
List of all sass directories that need to be compiled for the given themes.
"""
applicable_dirs = list()
for theme in themes:
# compile sass with theme overrides and place them in theme dir.
applicable_dirs.append({
"sass_source_dir": Path("ecommerce/static/sass/base"),
"css_destination_dir": theme.path / "static" / "css" / "base",
"lookup_paths": [theme.path / "static" / "sass" / "partials"] + SYSTEM_SASS_PATHS,
})
# Now, override existing css with any other sass overrides
theme_sass_dir = theme.path / "static" / "sass" / "base"
if theme_sass_dir.isdir():
applicable_dirs.append({
"sass_source_dir": theme.path / "static" / "sass" / "base",
"css_destination_dir": theme.path / "static" / "css" / "base",
"lookup_paths": [theme.path / "static" / "sass" / "partials"] + SYSTEM_SASS_PATHS,
})
return applicable_dirs
def compile_sass(sass_source_dir, css_destination_dir, lookup_paths, **kwargs):
"""
Compile given sass files.
Exceptions:
ValueError: Raised if sass source directory does not exist.
Args:
sass_source_dir (path.Path): directory path containing source sass files
css_destination_dir (path.Path): directory path where compiled css files would be placed
lookup_paths (list): a list of all paths that need to be consulted to resolve @imports from sass
Returns:
A tuple containing sass source dir, css destination dir and duration of sass compilation process
"""
output_style = kwargs.get('output_style', 'compressed')
source_comments = kwargs.get('source_comments', False)
start = datetime.datetime.now()
if not sass_source_dir.isdir():
logger.warning("Sass dir '%s' does not exist.", sass_source_dir)
raise ValueError("Sass dir '{dir}' must be a valid directory.".format(dir=sass_source_dir))
if not css_destination_dir.isdir():
# If css destination directory does not exist, then create one
css_destination_dir.mkdir_p()
sass.compile(
dirname=(sass_source_dir, css_destination_dir),
include_paths=lookup_paths,
source_comments=source_comments,
output_style=output_style,
)
duration = datetime.datetime.now() - start
return sass_source_dir, css_destination_dir, duration
def collect_assets():
"""
Collect static assets.
"""
logger.info("\t\tStarted collecting static assets.")
call_command("collectstatic")
logger.info("\t\tFinished collecting static assets.")
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sites', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SiteTheme',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('theme_dir_name', models.CharField(max_length=255)),
('site', models.ForeignKey(related_name='themes', to='sites.Site')),
],
),
]
from django.db import models
from django.contrib.sites.models import Site
class SiteTheme(models.Model):
"""
This is where the information about the site's theme gets stored to the db.
Fields:
site (ForeignKey): Foreign Key field pointing to django Site model
theme_dir_name (CharField): Contains directory name for any site's theme (e.g. 'red-theme')
"""
site = models.ForeignKey(Site, related_name='themes')
theme_dir_name = models.CharField(max_length=255)
"""
Comprehensive Theming support for Django's collectstatic functionality.
See https://docs.djangoproject.com/en/1.8/ref/contrib/staticfiles/
"""
import os.path
from django.conf import settings
from django.utils._os import safe_join
from django.contrib.staticfiles.storage import StaticFilesStorage
from ecommerce.theming.helpers import get_current_site_theme_dir, get_base_themes_dir, is_comprehensive_theming_enabled
class ThemeStorage(StaticFilesStorage):
"""
Comprehensive theme aware Static files storage.
"""
# prefix for file path, this prefix is added at the beginning of file path before saving static files during
# collectstatic command.
# e.g. having "edx.org" as prefix will cause files to be saved as "edx.org/images/logo.png"
# instead of "images/logo.png"
prefix = None
def __init__(self, location=None, base_url=None, file_permissions_mode=None,
directory_permissions_mode=None, prefix=None):
self.prefix = prefix
super(ThemeStorage, self).__init__(
location=location,
base_url=base_url,
file_permissions_mode=file_permissions_mode,
directory_permissions_mode=directory_permissions_mode,
)
def url(self, name):
"""
Returns url of the asset, themed url will be returned if the asset is themed otherwise default
asset url will be returned.
Args:
name: name of the asset, e.g. 'images/logo.png'
Returns:
url of the asset, e.g. '/static/red-theme/images/logo.png' if current theme is red-theme and logo
is provided by red-theme otherwise '/static/images/logo.png'
"""
prefix = ''
theme_dir = get_current_site_theme_dir()
# get theme prefix from site address if if asset is accessed via a url
if theme_dir:
prefix = theme_dir
# get theme prefix from storage class, if asset is accessed during collectstatic run
elif self.prefix:
prefix = self.prefix
# join theme prefix with asset name if theme is applied and themed asset exists
if prefix and self.themed(name, prefix):
name = os.path.join(prefix, name)
return super(ThemeStorage, self).url(name)
def themed(self, name, theme):
"""
Returns True if given asset override is provided by the given theme otherwise returns False.
Args:
name: asset name e.g. 'images/logo.png'
theme: theme name e.g. 'red-theme', 'edx.org'
Returns:
True if given asset override is provided by the given theme otherwise returns False
"""
if not is_comprehensive_theming_enabled():
return False
# in debug mode check static asset from within the project directory
if settings.DEBUG:
themes_location = get_base_themes_dir()
# Nothing can be themed if we don't have a theme location or required params.
if not all((themes_location, theme, name)):
return False
themed_path = "/".join([
themes_location,
theme,
"static/"
])
name = name[1:] if name.startswith("/") else name
path = safe_join(themed_path, name)
return os.path.exists(path)
# in live mode check static asset in the static files dir defined by "STATIC_ROOT" setting
else:
return self.exists(os.path.join(theme, name))
"""
Theming aware template loaders.
"""
from django.utils._os import safe_join
from django.core.exceptions import SuspiciousFileOperation
from django.template.loaders.filesystem import Loader as FilesystemLoader
from threadlocals.threadlocals import get_current_request
from ecommerce.theming.helpers import get_current_theme_template_dirs, get_all_theme_template_dirs
class ThemeTemplateLoader(FilesystemLoader):
"""
Filesystem Template loaders to pickup templates from theme directory based on the current site.
"""
is_usable = True
_accepts_engine_in_init = True
def get_template_sources(self, template_name, template_dirs=None):
"""
Returns the absolute paths to "template_name", when appended to each
directory in "template_dirs". Any paths that don't lie inside one of the
template dirs are excluded from the result set, for security reasons.
"""
if not template_dirs:
template_dirs = self.engine.dirs
theme_dirs = self.get_theme_template_sources()
# append theme dirs to the beginning so templates are looked up inside theme dir first
if isinstance(theme_dirs, list):
template_dirs = theme_dirs + template_dirs
for template_dir in template_dirs:
try:
yield safe_join(template_dir, template_name)
except SuspiciousFileOperation:
# The joined path was located outside of this template_dir
# (it might be inside another one, so this isn't fatal).
pass
@staticmethod
def get_theme_template_sources():
"""
Return template sources for the given theme and if request object is None (this would be the case for
management commands) return template sources for all themes.
"""
if not get_current_request():
# if request object is not present, then this method is being called inside a management
# command and return all theme template sources for compression
return get_all_theme_template_dirs()
else:
# template is being accessed by a view, so return templates sources for current theme
return get_current_theme_template_dirs()
"""
Test helpers for Comprehensive Theming.
"""
import re
from functools import wraps
from mock import patch
from django.contrib.sites.models import Site
from .models import SiteTheme
def with_comprehensive_theme(theme_dir_name):
"""
A decorator to run a test with a comprehensive theming enabled.
Arguments:
theme_dir_name (str): directory name of the site for which we want comprehensive theming enabled.
"""
# This decorator creates Site and SiteTheme models for given domain
def _decorator(func): # pylint: disable=missing-docstring
@wraps(func)
def _decorated(*args, **kwargs): # pylint: disable=missing-docstring
# make a domain name out of directory name
domain = "{theme_dir_name}.org".format(theme_dir_name=re.sub(r"\.org$", "", theme_dir_name))
site, __ = Site.objects.get_or_create(domain=domain, name=domain)
SiteTheme.objects.get_or_create(site=site, theme_dir_name=theme_dir_name)
with patch('ecommerce.theming.helpers.get_current_site_theme_dir',
return_value=theme_dir_name):
with patch('ecommerce.theming.helpers.get_current_site', return_value=site):
return func(*args, **kwargs)
return _decorated
return _decorator
"""
Comprehensive Theming tests for Theme App Config.
"""
import mock
from django.conf import settings
from django.test import TestCase, override_settings
from path import Path
from ecommerce.theming.apps import ThemeAppConfig
from ecommerce import theming
class TestThemeAppConfig(TestCase):
"""
Test comprehensive theming app config.
"""
def test_theme_config_ready(self):
"""
Tests enable theming is called in app config's ready method.
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
# make sure locale paths were added to LOCALE_PATHS setting
self.assertIn(themes_dir / "test-theme" / "conf" / "locale", settings.LOCALE_PATHS)
self.assertIn(themes_dir / "test-theme-2" / "conf" / "locale", settings.LOCALE_PATHS)
class TestThemeAppConfigThemingDisabled(TestCase):
"""
Test comprehensive theming app config.
"""
def test_ready_enable_theming(self):
"""
Tests that method `ready` invokes `enable_theming` method
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
config = ThemeAppConfig('theming', theming)
with mock.patch('ecommerce.theming.apps.enable_theming') as mock_enable_theming:
config.ready()
self.assertTrue(mock_enable_theming.called)
mock_enable_theming.assert_called_once_with(themes_dir=themes_dir)
@override_settings(ENABLE_COMPREHENSIVE_THEMING=False)
def test_ready_with_theming_disabled(self):
"""
Tests that method `ready` does not invoke `enable_theming` method when theming is disabled
"""
config = ThemeAppConfig('theming', theming)
with mock.patch('ecommerce.theming.apps.enable_theming') as mock_enable_theming:
config.ready()
self.assertFalse(mock_enable_theming.called)
"""
Tests for Management commands of comprehensive theming.
"""
from mock import patch, Mock
from django.conf import settings
from django.test import TestCase, override_settings
from django.core.management import call_command, CommandError
from path import Path
from ecommerce.theming.helpers import get_themes
from ecommerce.theming.management.commands.update_assets import (
get_sass_directories, compile_sass, Command, SYSTEM_SASS_PATHS,
)
class TestUpdateAssets(TestCase):
"""
Test comprehensive theming helper functions.
"""
def setUp(self):
super(TestUpdateAssets, self).setUp()
self.themes = get_themes()
def test_errors_for_invalid_arguments(self):
"""
Test update_asset command.
"""
# make sure error is raised for invalid theme list
with self.assertRaises(CommandError):
call_command("update_assets", themes=["all", "test-theme"])
# make sure error is raised for invalid theme list
with self.assertRaises(CommandError):
call_command("update_assets", themes=["no", "test-theme"])
# make sure error is raised for invalid theme list
with self.assertRaises(CommandError):
call_command("update_assets", themes=["all", "no"])
# make sure error is raised for invalid theme list
with self.assertRaises(CommandError):
call_command("update_assets", themes=["test-theme", "non-existing-theme"])
def test_parse_arguments(self):
"""
Test parse arguments method for update_asset command.
"""
# make sure update_assets picks all themes when called with 'themes=all' option
parsed_args = Command.parse_arguments(themes=["all"])
self.assertEqual(parsed_args[0], get_themes())
# make sure update_assets picks no themes when called with 'themes=no' option
parsed_args = Command.parse_arguments(themes=["no"])
self.assertEqual(parsed_args[0], [])
# make sure update_assets picks only specified themes
parsed_args = Command.parse_arguments(themes=["test-theme"])
self.assertEqual(parsed_args[0], [theme for theme in get_themes() if theme.theme_dir == "test-theme"])
def test_skip_theme_sass_when_theming_is_disabled(self):
"""
Test that theme sass is not compiled when theming is disabled.
"""
with override_settings(ENABLE_COMPREHENSIVE_THEMING=False):
with patch(
"ecommerce.theming.management.commands.update_assets.get_sass_directories",
) as mock_get_sass_dirs:
# make sure update_assets skip theme sass if theming is disabled eben if called with 'themes=all'
call_command("update_assets", "--skip-collect", themes=["all"])
mock_get_sass_dirs.assert_called_once_with([], True)
def test_get_sass_directories(self):
"""
Test that proper sass dirs are returned by get_sass_directories
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
expected_directories = [
{
"sass_source_dir": Path("ecommerce/static/sass/base"),
"css_destination_dir": Path("ecommerce/static/css/base"),
"lookup_paths": SYSTEM_SASS_PATHS,
},
{
"sass_source_dir": Path("ecommerce/static/sass/base"),
"css_destination_dir": themes_dir / "test-theme" / "static" / "css" / "base",
"lookup_paths": [themes_dir / "test-theme" / "static" / "sass" / "partials"] + SYSTEM_SASS_PATHS,
},
{
"sass_source_dir": Path("ecommerce/static/sass/base"),
"css_destination_dir": themes_dir / "test-theme-2" / "static" / "css" / "base",
"lookup_paths": [themes_dir / "test-theme-2" / "static" / "sass" / "partials"] + SYSTEM_SASS_PATHS,
},
{
"sass_source_dir": themes_dir / "test-theme-2" / "static" / "sass" / "base",
"css_destination_dir": themes_dir / "test-theme-2" / "static" / "css" / "base",
"lookup_paths": [themes_dir / "test-theme-2" / "static" / "sass" / "partials"] + SYSTEM_SASS_PATHS,
}
]
returned_dirs = get_sass_directories(themes=self.themes, system=True)
self.assertItemsEqual(expected_directories, returned_dirs)
def test_get_sass_directories_with_no_themes(self):
"""
Test that get_sass_directories returns only system sass directories when called
with empty list of themes and system=True
"""
expected_directories = [
{
"sass_source_dir": Path("ecommerce/static/sass/base"),
"css_destination_dir": Path("ecommerce/static/css/base"),
"lookup_paths": SYSTEM_SASS_PATHS,
}
]
returned_dirs = get_sass_directories(themes=[], system=True)
self.assertItemsEqual(expected_directories, returned_dirs)
def test_non_existent_sass_dir_error(self):
"""
Test ValueError is raised if sass directory provided to the compile_sass method does not exist.
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
with self.assertRaises(ValueError):
compile_sass(
sass_source_dir=themes_dir / "test-theme" / "sass" / "non-existent",
css_destination_dir=themes_dir / "test-theme" / "static" / "css" / "base",
lookup_paths=[themes_dir / "test-theme" / "static" / "sass" / "partials"] + SYSTEM_SASS_PATHS
)
def test_collect_static(self):
"""
Test that collect status is called when update_assets is called in production mode (i.e. DEBUG=False).
"""
with patch("ecommerce.theming.management.commands.update_assets.call_command", Mock()) as mock_call_command:
call_command("update_assets", "--skip-system", themes=[])
self.assertTrue(mock_call_command.called)
mock_call_command.assert_called_with("collectstatic")
def test_skip_collect(self):
"""
Test that call to collect status is skipped when --skip-collect is passed to update_assets command.
"""
with patch("ecommerce.theming.management.commands.update_assets.call_command", Mock()) as mock_call_command:
call_command("update_assets", "--skip-collect", "--skip-system", themes=[])
self.assertFalse(mock_call_command.called)
"""
Comprehensive Theming tests for core functionality.
"""
from django.conf import settings
from path import Path
from ecommerce.tests.testcases import TestCase
from ecommerce.theming.core import enable_theming
class TestCore(TestCase):
"""
Test comprehensive theming helper functions.
"""
def test_enable_theming(self):
"""
Tests for enable_theming method.
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
expected_locale_paths = (
themes_dir / "test-theme" / "conf" / "locale",
themes_dir / "test-theme-2" / "conf" / "locale",
) + settings.LOCALE_PATHS
enable_theming(settings.COMPREHENSIVE_THEME_DIR)
self.assertItemsEqual(expected_locale_paths, settings.LOCALE_PATHS)
def test_enable_theming_red_theme(self):
"""
Tests that locale path is added only if it exists.
"""
# Themes directory containing red-theme
themes_dir = settings.DJANGO_ROOT + "/themes"
# Note: red-theme does not contain translations dir
red_theme = Path(themes_dir + "/red-theme")
enable_theming(red_theme.dirname())
# Test that locale path is added only if it exists
self.assertNotIn(red_theme / "conf" / "locale", settings.LOCALE_PATHS)
"""
Tests for comprehensive theme static files finders.
"""
from django.conf import settings
from django.test import TestCase
from path import Path
from ecommerce.theming.finders import ThemeFilesFinder
class TestThemeFinders(TestCase):
"""
Test comprehensive theming static files finders.
"""
def setUp(self):
super(TestThemeFinders, self).setUp()
self.finder = ThemeFilesFinder()
def test_find_first_themed_asset(self):
"""
Verify Theme Finder returns themed assets
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
asset = "test-theme/images/default-logo.png"
match = self.finder.find(asset)
self.assertEqual(match, themes_dir / "test-theme" / "static" / "images" / "default-logo.png")
def test_find_all_themed_asset(self):
"""
Verify Theme Finder returns themed assets
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
asset = "test-theme/images/default-logo.png"
matches = self.finder.find(asset, all=True)
# Make sure only first match was returned
self.assertEqual(1, len(matches))
self.assertEqual(matches[0], themes_dir / "test-theme" / "static" / "images" / "default-logo.png")
def test_find_in_theme(self):
"""
Verify find in theme method of finders returns asset from specified theme
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
asset = "images/default-logo.png"
match = self.finder.find_in_theme("test-theme", asset)
self.assertEqual(match, themes_dir / "test-theme" / "static" / "images" / "default-logo.png")
"""
Tests of comprehensive theming.
"""
from mock import patch
from django.test import override_settings
from django.conf import settings, ImproperlyConfigured
from path import Path
from ecommerce.tests.testcases import TestCase
from ecommerce.theming.helpers import (
get_current_site_theme_dir, get_themes, Theme, get_theme_dir, get_current_theme_template_dirs,
get_all_theme_template_dirs, get_base_themes_dir,
)
from ecommerce.theming.test_utils import with_comprehensive_theme
class TestHelpers(TestCase):
"""
Test comprehensive theming helper functions.
"""
def test_get_themes(self):
"""
Tests get_themes returns all themes in themes directory.
"""
expected_themes = [
Theme('test-theme', 'test-theme'),
Theme('test-theme-2', 'test-theme-2'),
]
actual_themes = get_themes()
self.assertItemsEqual(expected_themes, actual_themes)
def test_get_themes_with_theming_disabled(self):
"""
Tests get_themes returns empty list when theming is disabled.
"""
with override_settings(ENABLE_COMPREHENSIVE_THEMING=False):
actual_themes = get_themes()
self.assertItemsEqual([], actual_themes)
@with_comprehensive_theme('test-theme')
def test_get_theme_dir(self):
"""
Tests get_theme_dir returns correct directory.
"""
theme_dir = get_theme_dir()
self.assertEqual(theme_dir, settings.DJANGO_ROOT + "/tests/themes/test-theme")
@with_comprehensive_theme('test-theme-2')
def test_get_theme_dir_2(self):
"""
Tests get_theme_dir returns correct directory.
"""
theme_dir = get_theme_dir()
self.assertEqual(theme_dir, settings.DJANGO_ROOT + "/tests/themes/test-theme-2")
def test_get_theme_dir_with_theming_disabled(self):
"""
Tests get_theme_dir returns None if theming is disabled.
"""
with override_settings(ENABLE_COMPREHENSIVE_THEMING=False):
theme_dir = get_theme_dir()
self.assertIsNone(theme_dir)
def test_improperly_configured_error(self):
"""
Tests ImproperlyConfigured error is raised when COMPREHENSIVE_THEME_DIR is not a string.
"""
with override_settings(COMPREHENSIVE_THEME_DIR=None):
with self.assertRaises(ImproperlyConfigured):
get_base_themes_dir()
def test_improperly_configured_error_for_invalid_dir(self):
"""
Tests ImproperlyConfigured error is raised when COMPREHENSIVE_THEME_DIR is not an existent path.
"""
with override_settings(COMPREHENSIVE_THEME_DIR="/path/to/non/existent/dir"):
with self.assertRaises(ImproperlyConfigured):
get_base_themes_dir()
def test_improperly_configured_error_for_relative_paths(self):
"""
Tests ImproperlyConfigured error is raised when COMPREHENSIVE_THEME_DIR is not an existent path.
"""
with override_settings(COMPREHENSIVE_THEME_DIR="ecommerce/tests/themes/tes-theme"):
with self.assertRaises(ImproperlyConfigured):
get_base_themes_dir()
@with_comprehensive_theme('test-theme')
def test_get_current_site_theme_dir(self):
"""
Tests current site theme name.
"""
current_site = get_current_site_theme_dir()
self.assertEqual(current_site, 'test-theme')
def test_get_current_site_theme_raises_no_error_when_accessed_in_commands(self):
"""
Tests current site theme returns None and does not errors out if it is accessed inside management commands
and request object is not present.
"""
with patch("ecommerce.theming.helpers.get_current_request", return_value=None):
current_site = get_current_site_theme_dir()
self.assertIsNone(current_site)
@with_comprehensive_theme('test-theme')
def test_get_current_theme_template_dirs(self):
"""
Tests get_current_theme_template_dirs returns correct template dirs for the current theme.
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
expected_theme_dirs = [
themes_dir / "test-theme" / "templates",
themes_dir / "test-theme" / "templates" / "oscar",
]
actual_theme_dirs = get_current_theme_template_dirs()
self.assertItemsEqual(expected_theme_dirs, actual_theme_dirs)
def test_get_all_theme_template_dirs(self):
"""
Tests get_all_theme_template_dirs returns correct template dirs for all the themes.
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
expected_theme_dirs = [
themes_dir / "test-theme" / "templates",
themes_dir / "test-theme" / "templates" / "oscar",
themes_dir / "test-theme-2" / "templates",
themes_dir / "test-theme-2" / "templates" / "oscar",
]
actual_theme_dirs = get_all_theme_template_dirs()
self.assertItemsEqual(expected_theme_dirs, actual_theme_dirs)
"""
Tests for comprehensive theme static files storage classes.
"""
from mock import patch
from django.test import override_settings
from django.conf import settings
from path import Path
from ecommerce.tests.testcases import TestCase
from ecommerce.theming.storage import ThemeStorage
@override_settings(DEBUG=True)
class TestThemeStorage(TestCase):
"""
Test comprehensive theming static files storage.
"""
def setUp(self):
super(TestThemeStorage, self).setUp()
self.themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
self.enabled_theme = "test-theme"
self.storage = ThemeStorage(location=self.themes_dir / self.enabled_theme / 'static')
def test_themed_asset(self):
"""
Verify storage returns True on themed assets
"""
asset = "images/default-logo.png"
self.assertTrue(self.storage.themed(asset, self.enabled_theme))
@override_settings(DEBUG=True)
def test_non_themed_asset(self):
"""
Verify storage returns False on assets that are not themed
"""
asset = "images/cap.png"
self.assertFalse(self.storage.themed(asset, self.enabled_theme))
def test_themed_with_theming_disabled(self):
"""
Verify storage returns False when theming is disabled even if given asset is themed
"""
asset = "images/default-logo.png"
with override_settings(ENABLE_COMPREHENSIVE_THEMING=False):
self.assertFalse(self.storage.themed(asset, self.enabled_theme))
def test_themed_missing_theme_name(self):
"""
Verify storage.themed returns False when theme name is empty or None.
"""
asset = "images/default-logo.png"
self.assertFalse(self.storage.themed(asset, ""))
self.assertFalse(self.storage.themed(asset, None))
def test_url(self):
"""
Verify storage returns correct url depending upon the enabled theme
"""
asset = "images/default-logo.png"
with patch(
"ecommerce.theming.storage.get_current_site_theme_dir",
return_value=self.enabled_theme,
):
asset_url = self.storage.url(asset)
# remove hash key from file url
expected_url = self.storage.base_url + self.enabled_theme + "/" + asset
self.assertEqual(asset_url, expected_url)
def test_path(self):
"""
Verify storage returns correct file path depending upon the enabled theme
"""
asset = "images/default-logo.png"
with patch(
"ecommerce.theming.storage.get_current_site_theme_dir",
return_value=self.enabled_theme,
):
returned_path = self.storage.path(asset)
expected_path = self.themes_dir / self.enabled_theme / "static" / asset
self.assertEqual(expected_path, returned_path)
"""
Tests for comprehensive theme style, template overrides.
"""
from django.conf import settings
from django.test import override_settings
from django.contrib import staticfiles
from django.core.management import call_command
from path import Path
from ecommerce.tests.testcases import TestCase
from ecommerce.theming.test_utils import with_comprehensive_theme
class TestComprehensiveTheme(TestCase):
"""
Test html, sass and static file overrides for comprehensive themes.
"""
def setUp(self):
"""
Clear static file finders cache and register cleanup methods.
"""
super(TestComprehensiveTheme, self).setUp()
# Clear the internal staticfiles caches, to get test isolation.
staticfiles.finders.get_finder.cache_clear()
# create a user and log in
self.user = self.create_user(is_staff=True)
self.client.login(username=self.user.username, password=self.password)
@classmethod
def setUpClass(cls):
"""
Enable Comprehensive theme and compile sass files.
"""
# compile sass assets for test themes.
compile_sass()
super(TestComprehensiveTheme, cls).setUpClass()
@with_comprehensive_theme("test-theme")
def test_templates(self):
"""
Test that theme template overrides are applied.
"""
with override_settings(COMPRESS_OFFLINE=False, COMPRESS_ENABLED=False):
resp = self.client.get('/dashboard/')
self.assertEqual(resp.status_code, 200)
# This string comes from header.html of test-theme
self.assertContains(resp, "This is a Test Theme.")
def test_logo_image(self):
"""
Test that theme logo is used instead of default logo.
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
result = staticfiles.finders.find('test-theme/images/default-logo.png')
self.assertEqual(result, themes_dir / "test-theme" / 'static/images/default-logo.png')
def test_css_files(self):
"""
Test that theme sass files are used instead of default sass files.
"""
themes_dir = Path(settings.COMPREHENSIVE_THEME_DIR)
result = staticfiles.finders.find('test-theme/css/base/main.css')
self.assertEqual(result, themes_dir / "test-theme" / "static/css/base/main.css")
main_css = ""
with open(result) as css_file:
main_css += css_file.read()
self.assertIn("background-color: #00fa00", main_css)
def compile_sass():
"""
Call update assets command to compile system and theme sass.
"""
with override_settings(DEBUG=True):
# Compile system and theme sass files
call_command('update_assets')
analytics-python==1.1.0 analytics-python==1.1.0
Django==1.8.9 Django==1.8.9
django-appconf==0.6 django-appconf==0.6
django-compressor==1.5 django-compressor==2.0
django_extensions==1.5.5 django_extensions==1.5.5
django-filter==0.11.0 django-filter==0.11.0
django-libsass==0.5 django-libsass==0.5
...@@ -32,3 +32,4 @@ python-social-auth==0.2.14 ...@@ -32,3 +32,4 @@ python-social-auth==0.2.14
pytz==2015.7 pytz==2015.7
requests==2.9.1 requests==2.9.1
suds==0.4 suds==0.4
path.py==7.2
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment