Manually merge release into master
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,32 +1,62 @@
|
||||
"""
|
||||
Core logic for Comprehensive Theming.
|
||||
"""
|
||||
import os.path
|
||||
from path import Path as path
|
||||
from path import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .helpers import (
|
||||
get_project_root_name,
|
||||
)
|
||||
|
||||
def comprehensive_theme_changes(theme_dir):
|
||||
"""
|
||||
Calculate the set of changes needed to enable a comprehensive theme.
|
||||
|
||||
Arguments:
|
||||
theme_dir (path.path): the full path to the theming directory to use.
|
||||
|
||||
Returns:
|
||||
A dict indicating the changes to make:
|
||||
|
||||
* 'settings': a dictionary of settings names and their new values.
|
||||
|
||||
* 'template_paths': a list of directories to prepend to template
|
||||
lookup path.
|
||||
|
||||
"""
|
||||
|
||||
changes = {
|
||||
'settings': {},
|
||||
'template_paths': [],
|
||||
}
|
||||
root = Path(settings.PROJECT_ROOT)
|
||||
if root.name == "":
|
||||
root = root.parent
|
||||
|
||||
component_dir = theme_dir / root.name
|
||||
|
||||
templates_dir = component_dir / "templates"
|
||||
if templates_dir.isdir():
|
||||
changes['template_paths'].append(templates_dir)
|
||||
|
||||
staticfiles_dir = component_dir / "static"
|
||||
if staticfiles_dir.isdir():
|
||||
changes['settings']['STATICFILES_DIRS'] = [staticfiles_dir] + settings.STATICFILES_DIRS
|
||||
|
||||
locale_dir = component_dir / "conf" / "locale"
|
||||
if locale_dir.isdir():
|
||||
changes['settings']['LOCALE_PATHS'] = [locale_dir] + settings.LOCALE_PATHS
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def enable_comprehensive_theming(themes_dir):
|
||||
def enable_comprehensive_theme(theme_dir):
|
||||
"""
|
||||
Add directories to relevant paths for comprehensive theming.
|
||||
:param themes_dir: path to base theme directory
|
||||
"""
|
||||
if isinstance(themes_dir, basestring):
|
||||
themes_dir = path(themes_dir)
|
||||
changes = comprehensive_theme_changes(theme_dir)
|
||||
|
||||
if themes_dir.isdir():
|
||||
settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].insert(0, themes_dir)
|
||||
settings.MAKO_TEMPLATES['main'].insert(0, themes_dir)
|
||||
|
||||
for theme_dir in os.listdir(themes_dir):
|
||||
staticfiles_dir = os.path.join(themes_dir, theme_dir, get_project_root_name(), "static")
|
||||
if staticfiles_dir.isdir():
|
||||
settings.STATICFILES_DIRS = settings.STATICFILES_DIRS + [staticfiles_dir]
|
||||
|
||||
locale_dir = os.path.join(themes_dir, theme_dir, get_project_root_name(), "conf", "locale")
|
||||
if locale_dir.isdir():
|
||||
settings.LOCALE_PATHS = (locale_dir, ) + settings.LOCALE_PATHS
|
||||
# Use the changes
|
||||
for name, value in changes['settings'].iteritems():
|
||||
setattr(settings, name, value)
|
||||
for template_dir in changes['template_paths']:
|
||||
settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].insert(0, template_dir)
|
||||
settings.MAKO_TEMPLATES['main'].insert(0, template_dir)
|
||||
|
||||
@@ -17,80 +17,63 @@ 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 path import Path
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.contrib.staticfiles import utils
|
||||
from django.contrib.staticfiles.finders import BaseFinder
|
||||
from django.utils import six
|
||||
|
||||
from openedx.core.djangoapps.theming.helpers import get_themes
|
||||
from openedx.core.djangoapps.theming.storage import ThemeStorage
|
||||
from openedx.core.djangoapps.theming.storage import CachedComprehensiveThemingStorage
|
||||
|
||||
|
||||
class ThemeFilesFinder(BaseFinder):
|
||||
class ComprehensiveThemeFinder(BaseFinder):
|
||||
"""
|
||||
A static files finder that looks in the directory of each theme as
|
||||
specified in the source_dir attribute.
|
||||
A static files finder that searches the active comprehensive theme
|
||||
for static files. If the ``COMPREHENSIVE_THEME_DIR`` setting is unset,
|
||||
or the ``COMPREHENSIVE_THEME_DIR`` does not exist on the file system,
|
||||
this finder will never find any files.
|
||||
"""
|
||||
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()
|
||||
super(ComprehensiveThemeFinder, self).__init__(*args, **kwargs)
|
||||
|
||||
themes = get_themes()
|
||||
for theme in themes:
|
||||
theme_storage = self.storage_class(
|
||||
os.path.join(theme.path, self.source_dir),
|
||||
prefix=theme.theme_dir,
|
||||
)
|
||||
theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "")
|
||||
if not theme_dir:
|
||||
self.storage = None
|
||||
return
|
||||
|
||||
self.storages[theme.theme_dir] = theme_storage
|
||||
if theme.theme_dir not in self.themes:
|
||||
self.themes.append(theme.theme_dir)
|
||||
if not isinstance(theme_dir, basestring):
|
||||
raise ImproperlyConfigured("Your COMPREHENSIVE_THEME_DIR setting must be a string")
|
||||
|
||||
super(ThemeFilesFinder, self).__init__(*args, **kwargs)
|
||||
root = Path(settings.PROJECT_ROOT)
|
||||
if root.name == "":
|
||||
root = root.parent
|
||||
|
||||
def list(self, ignore_patterns):
|
||||
"""
|
||||
List all files in all app 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
|
||||
component_dir = Path(theme_dir) / root.name
|
||||
static_dir = component_dir / "static"
|
||||
self.storage = CachedComprehensiveThemingStorage(location=static_dir)
|
||||
|
||||
def find(self, path, all=False): # pylint: disable=redefined-builtin
|
||||
"""
|
||||
Looks for files in the theme directories.
|
||||
Looks for files in the default file storage, if it's local.
|
||||
"""
|
||||
matches = []
|
||||
theme_dir = path.split("/", 1)[0]
|
||||
if not self.storage:
|
||||
return []
|
||||
|
||||
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
|
||||
if path.startswith(self.storage.prefix):
|
||||
# strip the prefix
|
||||
path = path[len(self.storage.prefix):]
|
||||
|
||||
def find_in_theme(self, theme, path):
|
||||
if self.storage.exists(path):
|
||||
match = self.storage.path(path)
|
||||
if all:
|
||||
match = [match]
|
||||
return match
|
||||
|
||||
return []
|
||||
|
||||
def list(self, ignore_patterns):
|
||||
"""
|
||||
Find a requested static file in an theme's static locations.
|
||||
List all files of the storage.
|
||||
"""
|
||||
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
|
||||
if self.storage and self.storage.exists(''):
|
||||
for path in utils.get_files(self.storage, ignore_patterns):
|
||||
yield path, self.storage
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"""
|
||||
Helpers for accessing comprehensive theming related variables.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
from path import Path
|
||||
|
||||
from django.conf import settings, ImproperlyConfigured
|
||||
from django.core.cache import cache
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
|
||||
from microsite_configuration import microsite
|
||||
from microsite_configuration import page_title_breadcrumbs
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_page_title_breadcrumbs(*args):
|
||||
@@ -31,11 +24,7 @@ def get_template_path(relative_path, **kwargs):
|
||||
"""
|
||||
This is a proxy function to hide microsite_configuration behind comprehensive theming.
|
||||
"""
|
||||
template_path = get_template_path_with_theme(relative_path)
|
||||
if template_path == relative_path: # we don't have a theme now look into microsites
|
||||
template_path = microsite.get_template_path(relative_path, **kwargs)
|
||||
|
||||
return template_path
|
||||
return microsite.get_template_path(relative_path, **kwargs)
|
||||
|
||||
|
||||
def is_request_in_themed_site():
|
||||
@@ -45,14 +34,6 @@ def is_request_in_themed_site():
|
||||
return microsite.is_request_in_microsite()
|
||||
|
||||
|
||||
def get_template(uri):
|
||||
"""
|
||||
This is a proxy function to hide microsite_configuration behind comprehensive theming.
|
||||
:param uri: uri of the template
|
||||
"""
|
||||
return microsite.get_template(uri)
|
||||
|
||||
|
||||
def get_themed_template_path(relative_path, default_path, **kwargs):
|
||||
"""
|
||||
This is a proxy function to hide microsite_configuration behind comprehensive theming.
|
||||
@@ -71,311 +52,3 @@ def get_themed_template_path(relative_path, default_path, **kwargs):
|
||||
if is_stanford_theming_enabled and not is_microsite:
|
||||
return relative_path
|
||||
return microsite.get_template_path(default_path, **kwargs)
|
||||
|
||||
|
||||
def get_template_path_with_theme(relative_path):
|
||||
"""
|
||||
Returns template path in current site's theme if it finds one there otherwise returns same path.
|
||||
|
||||
Example:
|
||||
>> get_template_path_with_theme('header')
|
||||
'/red-theme/lms/templates/header.html'
|
||||
|
||||
Parameters:
|
||||
relative_path (str): template's path relative to the templates directory e.g. 'footer.html'
|
||||
|
||||
Returns:
|
||||
(str): template path in current site's theme
|
||||
"""
|
||||
site_theme_dir = get_current_site_theme_dir()
|
||||
if not site_theme_dir:
|
||||
return relative_path
|
||||
|
||||
base_theme_dir = get_base_theme_dir()
|
||||
root_name = get_project_root_name()
|
||||
template_path = "/".join([
|
||||
base_theme_dir,
|
||||
site_theme_dir,
|
||||
root_name,
|
||||
"templates"
|
||||
])
|
||||
|
||||
# strip `/` if present at the start of relative_path
|
||||
template_name = re.sub(r'^/+', '', relative_path)
|
||||
search_path = os.path.join(template_path, template_name)
|
||||
if os.path.isfile(search_path):
|
||||
path = '/{site_theme_dir}/{root_name}/templates/{template_name}'.format(
|
||||
site_theme_dir=site_theme_dir,
|
||||
root_name=root_name,
|
||||
template_name=template_name,
|
||||
)
|
||||
return path
|
||||
else:
|
||||
return relative_path
|
||||
|
||||
|
||||
def get_current_theme_template_dirs():
|
||||
"""
|
||||
Returns template directories for the current theme.
|
||||
|
||||
Example:
|
||||
>> get_current_theme_template_dirs('header.html')
|
||||
['/edx/app/edxapp/edx-platform/themes/red-theme/lms/templates/', ]
|
||||
|
||||
Returns:
|
||||
(list): list of directories containing theme templates.
|
||||
"""
|
||||
site_theme_dir = get_current_site_theme_dir()
|
||||
if not site_theme_dir:
|
||||
return None
|
||||
|
||||
base_theme_dir = get_base_theme_dir()
|
||||
root_name = get_project_root_name()
|
||||
template_path = "/".join([
|
||||
base_theme_dir,
|
||||
site_theme_dir,
|
||||
root_name,
|
||||
"templates"
|
||||
])
|
||||
|
||||
return [template_path]
|
||||
|
||||
|
||||
def strip_site_theme_templates_path(uri):
|
||||
"""
|
||||
Remove site template theme path from the uri.
|
||||
|
||||
Example:
|
||||
>> strip_site_theme_templates_path('/red-theme/lms/templates/header.html')
|
||||
'header.html'
|
||||
|
||||
Arguments:
|
||||
uri (str): template path from which to remove site theme path. e.g. '/red-theme/lms/templates/header.html'
|
||||
|
||||
Returns:
|
||||
(str): template path with site theme path removed.
|
||||
"""
|
||||
site_theme_dir = get_current_site_theme_dir()
|
||||
if not site_theme_dir:
|
||||
return uri
|
||||
|
||||
root_name = get_project_root_name()
|
||||
templates_path = "/".join([
|
||||
site_theme_dir,
|
||||
root_name,
|
||||
"templates"
|
||||
])
|
||||
|
||||
uri = re.sub(r'^/*' + templates_path + '/*', '', uri)
|
||||
return uri
|
||||
|
||||
|
||||
def get_current_site():
|
||||
"""
|
||||
Return current site.
|
||||
|
||||
Returns:
|
||||
(django.contrib.sites.models.Site): theme directory for current site
|
||||
"""
|
||||
from edxmako.middleware import REQUEST_CONTEXT
|
||||
request = getattr(REQUEST_CONTEXT, 'request', None)
|
||||
if not request:
|
||||
return None
|
||||
return getattr(request, 'site', None)
|
||||
|
||||
|
||||
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_project_root_name():
|
||||
"""
|
||||
Return root name for the current project
|
||||
|
||||
Example:
|
||||
>> get_project_root_name()
|
||||
'lms'
|
||||
# from studio
|
||||
>> get_project_root_name()
|
||||
'cms'
|
||||
|
||||
Returns:
|
||||
(str): component name of platform e.g lms, cms
|
||||
"""
|
||||
root = Path(settings.PROJECT_ROOT)
|
||||
if root.name == "":
|
||||
root = root.parent
|
||||
return root.name
|
||||
|
||||
|
||||
def get_base_theme_dir():
|
||||
"""
|
||||
Return base directory that contains all the themes.
|
||||
|
||||
Example:
|
||||
>> get_base_theme_dir()
|
||||
'/edx/app/edxapp/edx-platform/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.")
|
||||
return Path(themes_dir)
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
return True if settings.COMPREHENSIVE_THEME_DIR else False
|
||||
|
||||
|
||||
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_static_file_url(asset):
|
||||
"""
|
||||
Returns url of the themed asset if asset is not themed than returns the default asset url.
|
||||
|
||||
Example:
|
||||
>> get_static_file_url('css/lms-main.css')
|
||||
'/static/red-theme/css/lms-main.css'
|
||||
|
||||
Parameters:
|
||||
asset (str): asset's path relative to the static files directory
|
||||
|
||||
Returns:
|
||||
(str): static asset's url
|
||||
"""
|
||||
return staticfiles_storage.url(asset)
|
||||
|
||||
|
||||
def get_themes():
|
||||
"""
|
||||
get a list of all themes known to the system.
|
||||
Returns:
|
||||
list of themes known to the system.
|
||||
"""
|
||||
themes_dir = get_base_theme_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 'lms' or 'cms' or both.
|
||||
|
||||
Args:
|
||||
_dir: directory path to check for a theme
|
||||
|
||||
Returns:
|
||||
Returns true if given dir is a theme directory.
|
||||
"""
|
||||
theme_sub_directories = {'lms', 'cms'}
|
||||
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 = ''
|
||||
path = ''
|
||||
|
||||
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
|
||||
self.path = Path(get_base_theme_dir()) / theme_dir / get_project_root_name()
|
||||
|
||||
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__()
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# -*- 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Django models supporting the Comprehensive Theming subsystem
|
||||
"""
|
||||
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.
|
||||
|
||||
`site` field is foreignkey to django Site model
|
||||
`theme_dir_name` contains directory name having Site's theme
|
||||
"""
|
||||
site = models.ForeignKey(Site, related_name='themes')
|
||||
theme_dir_name = models.CharField(max_length=255)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.theme_dir_name
|
||||
@@ -2,300 +2,87 @@
|
||||
Comprehensive Theming support for Django's collectstatic functionality.
|
||||
See https://docs.djangoproject.com/en/1.8/ref/contrib/staticfiles/
|
||||
"""
|
||||
import posixpath
|
||||
from path import Path
|
||||
import os.path
|
||||
from django.conf import settings
|
||||
from django.utils._os import safe_join
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage, CachedFilesMixin
|
||||
from django.contrib.staticfiles.finders import find
|
||||
from django.utils.six.moves.urllib.parse import ( # pylint: disable=no-name-in-module, import-error
|
||||
unquote, urlsplit,
|
||||
)
|
||||
|
||||
from pipeline.storage import PipelineMixin
|
||||
|
||||
from openedx.core.djangoapps.theming.helpers import (
|
||||
get_base_theme_dir,
|
||||
get_project_root_name,
|
||||
get_current_site_theme_dir,
|
||||
get_themes,
|
||||
)
|
||||
from django.utils._os import safe_join
|
||||
|
||||
|
||||
class ThemeStorage(StaticFilesStorage):
|
||||
class ComprehensiveThemingAwareMixin(object):
|
||||
"""
|
||||
Comprehensive theme aware Static files storage.
|
||||
Mixin for Django storage system to make it aware of the currently-active
|
||||
comprehensive theme, so that it can generate theme-scoped URLs for themed
|
||||
static assets.
|
||||
"""
|
||||
# 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
|
||||
"""
|
||||
# in debug mode check static asset from within the project directory
|
||||
if settings.DEBUG:
|
||||
themes_location = get_base_theme_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,
|
||||
get_project_root_name(),
|
||||
"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))
|
||||
|
||||
|
||||
class ComprehensiveThemingCachedFilesMixin(CachedFilesMixin):
|
||||
"""
|
||||
Comprehensive theme aware CachedFilesMixin.
|
||||
Main purpose of subclassing CachedFilesMixin is to override the following methods.
|
||||
1 - url
|
||||
2 - url_converter
|
||||
|
||||
url:
|
||||
This method takes asset name as argument and is responsible for adding hash to the name to support caching.
|
||||
This method is called during both collectstatic command and live server run.
|
||||
|
||||
When called during collectstatic command that name argument will be asset name inside STATIC_ROOT,
|
||||
for non themed assets it will be the usual path (e.g. 'images/logo.png') but for themed asset it will
|
||||
also contain themes dir prefix (e.g. 'red-theme/images/logo.png'). So, here we check whether the themed asset
|
||||
exists or not, if it exists we pass the same name up in the MRO chain for further processing and if it does not
|
||||
exists we strip theme name and pass the new asset name to the MRO chain for further processing.
|
||||
|
||||
When called during server run, we get the theme dir for the current site using `get_current_site_theme_dir` and
|
||||
make sure to prefix theme dir to the asset name. This is done to ensure the usage of correct hash in file name.
|
||||
e.g. if our red-theme overrides 'images/logo.png' and we do not prefix theme dir to the asset name, the hash for
|
||||
'{platform-dir}/lms/static/images/logo.png' would be used instead of
|
||||
'{themes_base_dir}/red-theme/images/logo.png'
|
||||
|
||||
url_converter:
|
||||
This function returns another function that is responsible for hashing urls that appear inside assets
|
||||
(e.g. url("images/logo.png") inside css). The method defined in the superclass adds a hash to file and returns
|
||||
relative url of the file.
|
||||
e.g. for url("../images/logo.png") it would return url("../images/logo.790c9a5340cb.png"). However we would
|
||||
want it to return absolute url (e.g. url("/static/images/logo.790c9a5340cb.png")) so that it works properly
|
||||
with themes.
|
||||
|
||||
The overridden method here simply comments out the two lines that convert absolute url to relative url,
|
||||
hence absolute urls are used instead of relative urls.
|
||||
"""
|
||||
|
||||
def url(self, name, force=False):
|
||||
"""
|
||||
Returns themed url for the given asset.
|
||||
"""
|
||||
theme_dir = get_current_site_theme_dir()
|
||||
if theme_dir and theme_dir not in name:
|
||||
# during server run, append theme name to the asset name if it is not already there
|
||||
# this is ensure that correct hash is created and default asset is not always
|
||||
# used to create hash of themed assets.
|
||||
name = os.path.join(theme_dir, name)
|
||||
parsed_name = urlsplit(unquote(name))
|
||||
clean_name = parsed_name.path.strip()
|
||||
asset_name = name
|
||||
if not self.exists(clean_name):
|
||||
# if themed asset does not exists then use default asset
|
||||
theme = name.split("/", 1)[0]
|
||||
# verify that themed asset was accessed
|
||||
if theme in [theme.theme_dir for theme in get_themes()]:
|
||||
asset_name = "/".join(name.split("/")[1:])
|
||||
|
||||
return super(ComprehensiveThemingCachedFilesMixin, self).url(asset_name, force)
|
||||
|
||||
def url_converter(self, name, template=None):
|
||||
"""
|
||||
This is an override of url_converter from CachedFilesMixin.
|
||||
It just comments out two lines at the end of the method.
|
||||
|
||||
The purpose of this override is to make converter method return absolute urls instead of relative urls.
|
||||
This behavior is necessary for theme overrides, as we get 404 on assets with relative urls on a themed site.
|
||||
"""
|
||||
if template is None:
|
||||
template = self.default_template
|
||||
|
||||
def converter(matchobj):
|
||||
"""
|
||||
Converts the matched URL depending on the parent level (`..`)
|
||||
and returns the normalized and hashed URL using the url method
|
||||
of the storage.
|
||||
"""
|
||||
matched, url = matchobj.groups()
|
||||
# Completely ignore http(s) prefixed URLs,
|
||||
# fragments and data-uri URLs
|
||||
if url.startswith(('#', 'http:', 'https:', 'data:', '//')):
|
||||
return matched
|
||||
name_parts = name.split(os.sep)
|
||||
# Using posix normpath here to remove duplicates
|
||||
url = posixpath.normpath(url)
|
||||
url_parts = url.split('/')
|
||||
parent_level, sub_level = url.count('..'), url.count('/')
|
||||
if url.startswith('/'):
|
||||
sub_level -= 1
|
||||
url_parts = url_parts[1:]
|
||||
if parent_level or not url.startswith('/'):
|
||||
start, end = parent_level + 1, parent_level
|
||||
else:
|
||||
if sub_level:
|
||||
if sub_level == 1:
|
||||
parent_level -= 1
|
||||
start, end = parent_level, 1
|
||||
else:
|
||||
start, end = 1, sub_level - 1
|
||||
joined_result = '/'.join(name_parts[:-start] + url_parts[end:])
|
||||
hashed_url = self.url(unquote(joined_result), force=True)
|
||||
|
||||
# NOTE:
|
||||
# following two lines are commented out so that absolute urls are used instead of relative urls
|
||||
# to make themed assets work correctly.
|
||||
#
|
||||
# The lines are commented and not removed to make future django upgrade easier and
|
||||
# show exactly what is changed in this method override
|
||||
#
|
||||
# file_name = hashed_url.split('/')[-1:]
|
||||
# relative_url = '/'.join(url.split('/')[:-1] + file_name)
|
||||
|
||||
# Return the hashed version to the file
|
||||
return template % unquote(hashed_url)
|
||||
|
||||
return converter
|
||||
|
||||
|
||||
class ThemePipelineMixin(PipelineMixin):
|
||||
"""
|
||||
Mixin to make sure themed assets are also packaged and used along with non themed assets.
|
||||
if a source asset for a particular package is not present then the default asset is used.
|
||||
|
||||
e.g. in the following package and for 'red-theme'
|
||||
'style-vendor': {
|
||||
'source_filenames': [
|
||||
'js/vendor/afontgarde/afontgarde.css',
|
||||
'css/vendor/font-awesome.css',
|
||||
'css/vendor/jquery.qtip.min.css',
|
||||
'css/vendor/responsive-carousel/responsive-carousel.css',
|
||||
'css/vendor/responsive-carousel/responsive-carousel.slide.css',
|
||||
],
|
||||
'output_filename': 'css/lms-style-vendor.css'
|
||||
}
|
||||
'red-theme/css/vendor/responsive-carousel/responsive-carousel.css' will be used of it exists otherwise
|
||||
'css/vendor/responsive-carousel/responsive-carousel.css' will be used to create 'red-theme/css/lms-style-vendor.css'
|
||||
"""
|
||||
packing = True
|
||||
|
||||
def post_process(self, paths, dry_run=False, **options):
|
||||
"""
|
||||
This post_process hook is used to package all themed assets.
|
||||
"""
|
||||
if dry_run:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ComprehensiveThemingAwareMixin, self).__init__(*args, **kwargs)
|
||||
theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "")
|
||||
if not theme_dir:
|
||||
self.theme_location = None
|
||||
return
|
||||
themes = get_themes()
|
||||
|
||||
for theme in themes:
|
||||
css_packages = self.get_themed_packages(theme.theme_dir, settings.PIPELINE_CSS)
|
||||
js_packages = self.get_themed_packages(theme.theme_dir, settings.PIPELINE_JS)
|
||||
if not isinstance(theme_dir, basestring):
|
||||
raise ImproperlyConfigured("Your COMPREHENSIVE_THEME_DIR setting must be a string")
|
||||
|
||||
from pipeline.packager import Packager
|
||||
packager = Packager(storage=self, css_packages=css_packages, js_packages=js_packages)
|
||||
for package_name in packager.packages['css']:
|
||||
package = packager.package_for('css', package_name)
|
||||
output_file = package.output_filename
|
||||
if self.packing:
|
||||
packager.pack_stylesheets(package)
|
||||
paths[output_file] = (self, output_file)
|
||||
yield output_file, output_file, True
|
||||
for package_name in packager.packages['js']:
|
||||
package = packager.package_for('js', package_name)
|
||||
output_file = package.output_filename
|
||||
if self.packing:
|
||||
packager.pack_javascripts(package)
|
||||
paths[output_file] = (self, output_file)
|
||||
yield output_file, output_file, True
|
||||
root = Path(settings.PROJECT_ROOT)
|
||||
if root.name == "":
|
||||
root = root.parent
|
||||
|
||||
super_class = super(ThemePipelineMixin, self)
|
||||
if hasattr(super_class, 'post_process'):
|
||||
for name, hashed_name, processed in super_class.post_process(paths.copy(), dry_run, **options):
|
||||
yield name, hashed_name, processed
|
||||
component_dir = Path(theme_dir) / root.name
|
||||
self.theme_location = component_dir / "static"
|
||||
|
||||
@staticmethod
|
||||
def get_themed_packages(prefix, packages):
|
||||
@property
|
||||
def prefix(self):
|
||||
"""
|
||||
Update paths with the themed assets,
|
||||
Args:
|
||||
prefix: theme prefix for which to update asset paths e.g. 'red-theme', 'edx.org' etc.
|
||||
packages: packages to update
|
||||
|
||||
Returns: list of updated paths and a boolean indicating whether any path was path or not
|
||||
This is used by the ComprehensiveThemeFinder in the collection step.
|
||||
"""
|
||||
themed_packages = {}
|
||||
for name in packages:
|
||||
# collect source file names for the package
|
||||
source_files = []
|
||||
for path in packages[name].get('source_filenames', []):
|
||||
# if themed asset exists use that, otherwise use default asset.
|
||||
if find(os.path.join(prefix, path)):
|
||||
source_files.append(os.path.join(prefix, path))
|
||||
else:
|
||||
source_files.append(path)
|
||||
theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "")
|
||||
if not theme_dir:
|
||||
return None
|
||||
theme_name = os.path.basename(os.path.normpath(theme_dir))
|
||||
return "themes/{name}/".format(name=theme_name)
|
||||
|
||||
themed_packages[name] = {
|
||||
'output_filename': os.path.join(prefix, packages[name].get('output_filename', '')),
|
||||
'source_filenames': source_files,
|
||||
}
|
||||
return themed_packages
|
||||
def themed(self, name):
|
||||
"""
|
||||
Given a name, return a boolean indicating whether that name exists
|
||||
as a themed asset in the comprehensive theme.
|
||||
"""
|
||||
# Nothing can be themed if we don't have a theme location.
|
||||
if not self.theme_location:
|
||||
return False
|
||||
|
||||
path = safe_join(self.theme_location, name)
|
||||
return os.path.exists(path)
|
||||
|
||||
def path(self, name):
|
||||
"""
|
||||
Get the path to the real asset on disk
|
||||
"""
|
||||
if self.themed(name):
|
||||
base = self.theme_location
|
||||
else:
|
||||
base = self.location
|
||||
path = safe_join(base, name)
|
||||
return os.path.normpath(path)
|
||||
|
||||
def url(self, name, *args, **kwargs):
|
||||
"""
|
||||
Add the theme prefix to the asset URL
|
||||
"""
|
||||
if self.themed(name):
|
||||
name = self.prefix + name
|
||||
return super(ComprehensiveThemingAwareMixin, self).url(name, *args, **kwargs)
|
||||
|
||||
|
||||
class CachedComprehensiveThemingStorage(
|
||||
ComprehensiveThemingAwareMixin,
|
||||
CachedFilesMixin,
|
||||
StaticFilesStorage
|
||||
):
|
||||
"""
|
||||
Used by the ComprehensiveThemeFinder class. Mixes in support for cached
|
||||
files and comprehensive theming in static files.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""
|
||||
Theming aware template loaders.
|
||||
"""
|
||||
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
||||
|
||||
from edxmako.makoloader import MakoLoader
|
||||
from openedx.core.djangoapps.theming.helpers import get_template_path_with_theme
|
||||
|
||||
|
||||
class ThemeTemplateLoader(MakoLoader):
|
||||
"""
|
||||
This is a Django loader object which will load the template based on current request and its corresponding theme.
|
||||
"""
|
||||
def __call__(self, template_name, template_dirs=None):
|
||||
template_name = get_template_path_with_theme(template_name).lstrip("/")
|
||||
return self.load_template(template_name, template_dirs)
|
||||
|
||||
|
||||
class ThemeFilesystemLoader(ThemeTemplateLoader):
|
||||
"""
|
||||
Filesystem Template loaders to pickup templates from theme directory based on the current site.
|
||||
"""
|
||||
is_usable = True
|
||||
_accepts_engine_in_init = True
|
||||
|
||||
def __init__(self, *args):
|
||||
ThemeTemplateLoader.__init__(self, FilesystemLoader(*args))
|
||||
@@ -1,78 +0,0 @@
|
||||
"""
|
||||
Theme aware pipeline template tags.
|
||||
"""
|
||||
|
||||
from django import template
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from pipeline.templatetags.pipeline import StylesheetNode, JavascriptNode
|
||||
from pipeline.utils import guess_type
|
||||
|
||||
from openedx.core.djangoapps.theming.helpers import get_static_file_url
|
||||
|
||||
register = template.Library() # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class ThemeStylesheetNode(StylesheetNode):
|
||||
"""
|
||||
Overrides StyleSheetNode from django pipeline so that stylesheets are served based on the applied theme.
|
||||
"""
|
||||
def render_css(self, package, path):
|
||||
"""
|
||||
Override render_css from django-pipline so that stylesheets urls are based on the applied theme
|
||||
"""
|
||||
template_name = package.template_name or "pipeline/css.html"
|
||||
context = package.extra_context
|
||||
context.update({
|
||||
'type': guess_type(path, 'text/css'),
|
||||
'url': mark_safe(get_static_file_url(path))
|
||||
})
|
||||
return render_to_string(template_name, context)
|
||||
|
||||
|
||||
class ThemeJavascriptNode(JavascriptNode):
|
||||
"""
|
||||
Overrides JavascriptNode from django pipeline so that js files are served based on the applied theme.
|
||||
"""
|
||||
def render_js(self, package, path):
|
||||
"""
|
||||
Override render_js from django-pipline so that js file urls are based on the applied theme
|
||||
"""
|
||||
template_name = package.template_name or "pipeline/js.html"
|
||||
context = package.extra_context
|
||||
context.update({
|
||||
'type': guess_type(path, 'text/javascript'),
|
||||
'url': mark_safe(get_static_file_url(path))
|
||||
})
|
||||
return render_to_string(template_name, context)
|
||||
|
||||
|
||||
@register.tag
|
||||
def stylesheet(parser, token): # pylint: disable=unused-argument
|
||||
"""
|
||||
Template tag to serve stylesheets from django-pipeline. This definition uses the theming aware ThemeStyleSheetNode.
|
||||
"""
|
||||
try:
|
||||
_, name = token.split_contents()
|
||||
except ValueError:
|
||||
raise template.TemplateSyntaxError(
|
||||
'%r requires exactly one argument: the name of a group in the PIPELINE_CSS setting' %
|
||||
token.split_contents()[0]
|
||||
)
|
||||
return ThemeStylesheetNode(name)
|
||||
|
||||
|
||||
@register.tag
|
||||
def javascript(parser, token): # pylint: disable=unused-argument
|
||||
"""
|
||||
Template tag to serve javascript from django-pipeline. This definition uses the theming aware ThemeJavascriptNode.
|
||||
"""
|
||||
try:
|
||||
_, name = token.split_contents()
|
||||
except ValueError:
|
||||
raise template.TemplateSyntaxError(
|
||||
'%r requires exactly one argument: the name of a group in the PIPELINE_JS setting' %
|
||||
token.split_contents()[0]
|
||||
)
|
||||
return ThemeJavascriptNode(name)
|
||||
@@ -6,57 +6,87 @@ from functools import wraps
|
||||
import os
|
||||
import os.path
|
||||
import contextlib
|
||||
import re
|
||||
|
||||
from mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.template import Engine
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import edxmako
|
||||
from .models import SiteTheme
|
||||
|
||||
from .core import comprehensive_theme_changes
|
||||
|
||||
EDX_THEME_DIR = settings.REPO_ROOT / "themes" / "edx.org"
|
||||
|
||||
|
||||
def with_comprehensive_theme(theme_dir_name):
|
||||
def with_comprehensive_theme(theme_dir):
|
||||
"""
|
||||
A decorator to run a test with a comprehensive theming enabled.
|
||||
A decorator to run a test with a particular comprehensive theme.
|
||||
|
||||
Arguments:
|
||||
theme_dir_name (str): directory name of the site for which we want comprehensive theming enabled.
|
||||
theme_dir (str): the full path to the theme directory to use.
|
||||
This will likely use `settings.REPO_ROOT` to get the full path.
|
||||
|
||||
"""
|
||||
# This decorator creates Site and SiteTheme models for given domain
|
||||
# This decorator gets the settings changes needed for a theme, and applies
|
||||
# them using the override_settings and edxmako.paths.add_lookup context
|
||||
# managers.
|
||||
|
||||
changes = comprehensive_theme_changes(theme_dir)
|
||||
|
||||
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)
|
||||
edxmako.paths.add_lookup('main', settings.COMPREHENSIVE_THEME_DIR, prepend=True)
|
||||
with patch('openedx.core.djangoapps.theming.helpers.get_current_site_theme_dir',
|
||||
return_value=theme_dir_name):
|
||||
with patch('openedx.core.djangoapps.theming.helpers.get_current_site', return_value=site):
|
||||
return func(*args, **kwargs)
|
||||
with override_settings(COMPREHENSIVE_THEME_DIR=theme_dir, **changes['settings']):
|
||||
default_engine = Engine.get_default()
|
||||
dirs = default_engine.dirs[:]
|
||||
with edxmako.save_lookups():
|
||||
for template_dir in changes['template_paths']:
|
||||
edxmako.paths.add_lookup('main', template_dir, prepend=True)
|
||||
dirs.insert(0, template_dir)
|
||||
with patch.object(default_engine, 'dirs', dirs):
|
||||
return func(*args, **kwargs)
|
||||
return _decorated
|
||||
return _decorator
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def with_comprehensive_theme_context(theme=None):
|
||||
def with_is_edx_domain(is_edx_domain):
|
||||
"""
|
||||
A function to run a test as if request was made to the given theme.
|
||||
A decorator to run a test as if request originated from edX domain or not.
|
||||
|
||||
Arguments:
|
||||
theme (str): name if the theme or None if no theme is applied
|
||||
is_edx_domain (bool): are we an edX domain or not?
|
||||
|
||||
"""
|
||||
if theme:
|
||||
domain = '{theme}.org'.format(theme=re.sub(r"\.org$", "", theme))
|
||||
site, __ = Site.objects.get_or_create(domain=domain, name=theme)
|
||||
SiteTheme.objects.get_or_create(site=site, theme_dir_name=theme)
|
||||
edxmako.paths.add_lookup('main', settings.COMPREHENSIVE_THEME_DIR, prepend=True)
|
||||
with patch('openedx.core.djangoapps.theming.helpers.get_current_site_theme_dir',
|
||||
return_value=theme):
|
||||
with patch('openedx.core.djangoapps.theming.helpers.get_current_site', return_value=site):
|
||||
# This is weird, it's a decorator that conditionally applies other
|
||||
# decorators, which is confusing.
|
||||
def _decorator(func): # pylint: disable=missing-docstring
|
||||
if is_edx_domain:
|
||||
# This applies @with_comprehensive_theme to the func.
|
||||
func = with_comprehensive_theme(EDX_THEME_DIR)(func)
|
||||
|
||||
return func
|
||||
|
||||
return _decorator
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def with_edx_domain_context(is_edx_domain):
|
||||
"""
|
||||
A function to run a test as if request originated from edX domain or not.
|
||||
|
||||
Arguments:
|
||||
is_edx_domain (bool): are we an edX domain or not?
|
||||
|
||||
"""
|
||||
if is_edx_domain:
|
||||
changes = comprehensive_theme_changes(EDX_THEME_DIR)
|
||||
with override_settings(COMPREHENSIVE_THEME_DIR=EDX_THEME_DIR, **changes['settings']):
|
||||
with edxmako.save_lookups():
|
||||
for template_dir in changes['template_paths']:
|
||||
edxmako.paths.add_lookup('main', template_dir, prepend=True)
|
||||
|
||||
yield
|
||||
else:
|
||||
yield
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Tests of comprehensive theming."""
|
||||
import unittest
|
||||
from mock import patch
|
||||
|
||||
from django.test import TestCase, RequestFactory, override_settings
|
||||
from django.conf import settings
|
||||
|
||||
from openedx.core.djangoapps.theming.test_util import with_comprehensive_theme
|
||||
from openedx.core.djangoapps.theming.helpers import get_template_path_with_theme, strip_site_theme_templates_path, \
|
||||
get_current_site_theme_dir, get_themes, Theme
|
||||
|
||||
|
||||
class TestHelpers(TestCase):
|
||||
"""Test comprehensive theming helper functions."""
|
||||
|
||||
def test_get_themes(self):
|
||||
"""
|
||||
Tests template paths are returned from enabled theme.
|
||||
"""
|
||||
expected_themes = [
|
||||
Theme('red-theme', 'red-theme'),
|
||||
Theme('edge.edx.org', 'edge.edx.org'),
|
||||
Theme('edx.org', 'edx.org'),
|
||||
Theme('stanford-style', 'stanford-style'),
|
||||
]
|
||||
actual_themes = get_themes()
|
||||
self.assertItemsEqual(expected_themes, actual_themes)
|
||||
|
||||
@override_settings(COMPREHENSIVE_THEME_DIR=settings.TEST_THEME.dirname())
|
||||
def test_get_themes_2(self):
|
||||
"""
|
||||
Tests template paths are returned from enabled theme.
|
||||
"""
|
||||
expected_themes = [
|
||||
Theme('test-theme', 'test-theme'),
|
||||
]
|
||||
actual_themes = get_themes()
|
||||
self.assertItemsEqual(expected_themes, actual_themes)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class TestHelpersLMS(TestCase):
|
||||
"""Test comprehensive theming helper functions."""
|
||||
|
||||
@with_comprehensive_theme('red-theme')
|
||||
def test_get_template_path_with_theme_enabled(self):
|
||||
"""
|
||||
Tests template paths are returned from enabled theme.
|
||||
"""
|
||||
template_path = get_template_path_with_theme('header.html')
|
||||
self.assertEqual(template_path, '/red-theme/lms/templates/header.html')
|
||||
|
||||
@with_comprehensive_theme('red-theme')
|
||||
def test_get_template_path_with_theme_for_missing_template(self):
|
||||
"""
|
||||
Tests default template paths are returned if template is not found in the theme.
|
||||
"""
|
||||
template_path = get_template_path_with_theme('course.html')
|
||||
self.assertEqual(template_path, 'course.html')
|
||||
|
||||
def test_get_template_path_with_theme_disabled(self):
|
||||
"""
|
||||
Tests default template paths are returned when theme is non theme is enabled.
|
||||
"""
|
||||
template_path = get_template_path_with_theme('header.html')
|
||||
self.assertEqual(template_path, 'header.html')
|
||||
|
||||
@with_comprehensive_theme('red-theme')
|
||||
def test_strip_site_theme_templates_path_theme_enabled(self):
|
||||
"""
|
||||
Tests site theme templates path is stripped from the given template path.
|
||||
"""
|
||||
template_path = strip_site_theme_templates_path('/red-theme/lms/templates/header.html')
|
||||
self.assertEqual(template_path, 'header.html')
|
||||
|
||||
def test_strip_site_theme_templates_path_theme_disabled(self):
|
||||
"""
|
||||
Tests site theme templates path returned unchanged if no theme is applied.
|
||||
"""
|
||||
template_path = strip_site_theme_templates_path('/red-theme/lms/templates/header.html')
|
||||
self.assertEqual(template_path, '/red-theme/lms/templates/header.html')
|
||||
|
||||
@with_comprehensive_theme('red-theme')
|
||||
def test_get_current_site_theme_dir(self):
|
||||
"""
|
||||
Tests current site theme name.
|
||||
"""
|
||||
factory = RequestFactory()
|
||||
with patch(
|
||||
'edxmako.middleware.REQUEST_CONTEXT.request',
|
||||
factory.get('/', SERVER_NAME="red-theme.org"),
|
||||
create=True,
|
||||
):
|
||||
current_site = get_current_site_theme_dir()
|
||||
self.assertEqual(current_site, 'red-theme')
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
|
||||
class TestHelpersCMS(TestCase):
|
||||
"""Test comprehensive theming helper functions."""
|
||||
|
||||
@with_comprehensive_theme('red-theme')
|
||||
def test_get_template_path_with_theme_enabled(self):
|
||||
"""
|
||||
Tests template paths are returned from enabled theme.
|
||||
"""
|
||||
template_path = get_template_path_with_theme('login.html')
|
||||
self.assertEqual(template_path, '/red-theme/cms/templates/login.html')
|
||||
|
||||
@with_comprehensive_theme('red-theme')
|
||||
def test_get_template_path_with_theme_for_missing_template(self):
|
||||
"""
|
||||
Tests default template paths are returned if template is not found in the theme.
|
||||
"""
|
||||
template_path = get_template_path_with_theme('certificates.html')
|
||||
self.assertEqual(template_path, 'certificates.html')
|
||||
|
||||
def test_get_template_path_with_theme_disabled(self):
|
||||
"""
|
||||
Tests default template paths are returned when theme is non theme is enabled.
|
||||
"""
|
||||
template_path = get_template_path_with_theme('login.html')
|
||||
self.assertEqual(template_path, 'login.html')
|
||||
|
||||
@with_comprehensive_theme('red-theme')
|
||||
def test_strip_site_theme_templates_path_theme_enabled(self):
|
||||
"""
|
||||
Tests site theme templates path is stripped from the given template path.
|
||||
"""
|
||||
template_path = strip_site_theme_templates_path('/red-theme/cms/templates/login.html')
|
||||
self.assertEqual(template_path, 'login.html')
|
||||
|
||||
def test_strip_site_theme_templates_path_theme_disabled(self):
|
||||
"""
|
||||
Tests site theme templates path returned unchanged if no theme is applied.
|
||||
"""
|
||||
template_path = strip_site_theme_templates_path('/red-theme/cms/templates/login.html')
|
||||
self.assertEqual(template_path, '/red-theme/cms/templates/login.html')
|
||||
|
||||
@with_comprehensive_theme('red-theme')
|
||||
def test_get_current_site_theme_dir(self):
|
||||
"""
|
||||
Tests current site theme name.
|
||||
"""
|
||||
factory = RequestFactory()
|
||||
with patch(
|
||||
'edxmako.middleware.REQUEST_CONTEXT.request',
|
||||
factory.get('/', SERVER_NAME="red-theme.org"),
|
||||
create=True,
|
||||
):
|
||||
current_site = get_current_site_theme_dir()
|
||||
self.assertEqual(current_site, 'red-theme')
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
Tests for comprehensive theme static files storage classes.
|
||||
"""
|
||||
import ddt
|
||||
import unittest
|
||||
import re
|
||||
|
||||
from mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.conf import settings
|
||||
|
||||
from openedx.core.djangoapps.theming.helpers import get_base_theme_dir
|
||||
from openedx.core.djangoapps.theming.storage import ThemeStorage
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@ddt.ddt
|
||||
class TestStorageLMS(TestCase):
|
||||
"""
|
||||
Test comprehensive theming static files storage.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestStorageLMS, self).setUp()
|
||||
self.themes_dir = get_base_theme_dir()
|
||||
self.enabled_theme = "red-theme"
|
||||
self.system_dir = settings.REPO_ROOT / "lms"
|
||||
self.storage = ThemeStorage(location=self.themes_dir / self.enabled_theme / 'lms' / 'static')
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
@ddt.data(
|
||||
(True, "images/logo.png"),
|
||||
(True, "images/favicon.ico"),
|
||||
(False, "images/spinning.gif"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_themed(self, is_themed, asset):
|
||||
"""
|
||||
Verify storage returns True on themed assets
|
||||
"""
|
||||
self.assertEqual(is_themed, self.storage.themed(asset, self.enabled_theme))
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
@ddt.data(
|
||||
("images/logo.png", ),
|
||||
("images/favicon.ico", ),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_url(self, asset):
|
||||
"""
|
||||
Verify storage returns correct url depending upon the enabled theme
|
||||
"""
|
||||
with patch(
|
||||
"openedx.core.djangoapps.theming.storage.get_current_site_theme_dir",
|
||||
return_value=self.enabled_theme,
|
||||
):
|
||||
asset_url = self.storage.url(asset)
|
||||
# remove hash key from file url
|
||||
asset_url = re.sub(r"(\.\w+)(\.png|\.ico)$", r"\g<2>", asset_url)
|
||||
expected_url = self.storage.base_url + self.enabled_theme + "/" + asset
|
||||
|
||||
self.assertEqual(asset_url, expected_url)
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
@ddt.data(
|
||||
("images/logo.png", ),
|
||||
("images/favicon.ico", ),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_path(self, asset):
|
||||
"""
|
||||
Verify storage returns correct file path depending upon the enabled theme
|
||||
"""
|
||||
with patch(
|
||||
"openedx.core.djangoapps.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 / "lms/static/" / asset
|
||||
|
||||
self.assertEqual(expected_path, returned_path)
|
||||
@@ -1,235 +0,0 @@
|
||||
"""
|
||||
Tests for comprehensive themes.
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, override_settings
|
||||
from django.contrib import staticfiles
|
||||
|
||||
from paver.easy import call_task
|
||||
|
||||
from openedx.core.djangoapps.theming.test_util import with_comprehensive_theme
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class TestComprehensiveThemeLMS(TestCase):
|
||||
"""
|
||||
Test html, sass and static file overrides for comprehensive themes.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Clear static file finders cache and register cleanup methods.
|
||||
"""
|
||||
super(TestComprehensiveThemeLMS, self).setUp()
|
||||
|
||||
# Clear the internal staticfiles caches, to get test isolation.
|
||||
staticfiles.finders.get_finder.cache_clear()
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""
|
||||
Enable Comprehensive theme and compile sass files.
|
||||
"""
|
||||
# Apply Comprehensive theme and compile sass assets.
|
||||
compile_sass('lms')
|
||||
|
||||
super(TestComprehensiveThemeLMS, cls).setUpClass()
|
||||
|
||||
@override_settings(COMPREHENSIVE_THEME_DIR=settings.TEST_THEME.dirname())
|
||||
@with_comprehensive_theme(settings.TEST_THEME.basename())
|
||||
def test_footer(self):
|
||||
"""
|
||||
Test that theme footer is used instead of default footer.
|
||||
"""
|
||||
resp = self.client.get('/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# This string comes from header.html of test-theme
|
||||
self.assertContains(resp, "This is a footer for test-theme.")
|
||||
|
||||
@override_settings(COMPREHENSIVE_THEME_DIR=settings.TEST_THEME.dirname())
|
||||
@with_comprehensive_theme(settings.TEST_THEME.basename())
|
||||
def test_logo_image(self):
|
||||
"""
|
||||
Test that theme logo is used instead of default logo.
|
||||
"""
|
||||
result = staticfiles.finders.find('test-theme/images/logo.png')
|
||||
self.assertEqual(result, settings.TEST_THEME / 'lms/static/images/logo.png')
|
||||
|
||||
@override_settings(COMPREHENSIVE_THEME_DIR=settings.TEST_THEME.dirname())
|
||||
@with_comprehensive_theme(settings.TEST_THEME.basename())
|
||||
def test_css_files(self):
|
||||
"""
|
||||
Test that theme sass files are used instead of default sass files.
|
||||
"""
|
||||
result = staticfiles.finders.find('test-theme/css/lms-main-v1.css')
|
||||
self.assertEqual(result, settings.TEST_THEME / "lms/static/css/lms-main-v1.css")
|
||||
|
||||
lms_main_css = ""
|
||||
with open(result) as css_file:
|
||||
lms_main_css += css_file.read()
|
||||
|
||||
self.assertIn("background:#00fa00", lms_main_css)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
|
||||
class TestComprehensiveThemeCMS(TestCase):
|
||||
"""
|
||||
Test html, sass and static file overrides for comprehensive themes.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Clear static file finders cache and register cleanup methods.
|
||||
"""
|
||||
super(TestComprehensiveThemeCMS, self).setUp()
|
||||
|
||||
# Clear the internal staticfiles caches, to get test isolation.
|
||||
staticfiles.finders.get_finder.cache_clear()
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""
|
||||
Enable Comprehensive theme and compile sass files.
|
||||
"""
|
||||
# Apply Comprehensive theme and compile sass assets.
|
||||
compile_sass('cms')
|
||||
|
||||
super(TestComprehensiveThemeCMS, cls).setUpClass()
|
||||
|
||||
@override_settings(COMPREHENSIVE_THEME_DIR=settings.TEST_THEME.dirname())
|
||||
@with_comprehensive_theme(settings.TEST_THEME.basename())
|
||||
def test_template_override(self):
|
||||
"""
|
||||
Test that theme templates are used instead of default templates.
|
||||
"""
|
||||
resp = self.client.get('/signin')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# This string comes from login.html of test-theme
|
||||
self.assertContains(resp, "Login Page override for test-theme.")
|
||||
|
||||
@override_settings(COMPREHENSIVE_THEME_DIR=settings.TEST_THEME.dirname())
|
||||
@with_comprehensive_theme(settings.TEST_THEME.basename())
|
||||
def test_css_files(self):
|
||||
"""
|
||||
Test that theme sass files are used instead of default sass files.
|
||||
"""
|
||||
result = staticfiles.finders.find('test-theme/css/studio-main-v1.css')
|
||||
self.assertEqual(result, settings.TEST_THEME / "cms/static/css/studio-main-v1.css")
|
||||
|
||||
cms_main_css = ""
|
||||
with open(result) as css_file:
|
||||
cms_main_css += css_file.read()
|
||||
|
||||
self.assertIn("background:#00fa00", cms_main_css)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class TestComprehensiveThemeDisabledLMS(TestCase):
|
||||
"""
|
||||
Test Sass compilation order and sass overrides for comprehensive themes.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Clear static file finders cache.
|
||||
"""
|
||||
super(TestComprehensiveThemeDisabledLMS, self).setUp()
|
||||
|
||||
# Clear the internal staticfiles caches, to get test isolation.
|
||||
staticfiles.finders.get_finder.cache_clear()
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""
|
||||
Compile sass files.
|
||||
"""
|
||||
# compile LMS SASS
|
||||
compile_sass('lms')
|
||||
|
||||
super(TestComprehensiveThemeDisabledLMS, cls).setUpClass()
|
||||
|
||||
def test_logo(self):
|
||||
"""
|
||||
Test that default logo is picked in case of no comprehensive theme.
|
||||
"""
|
||||
result = staticfiles.finders.find('images/logo.png')
|
||||
self.assertEqual(result, settings.REPO_ROOT / 'lms/static/images/logo.png')
|
||||
|
||||
def test_css(self):
|
||||
"""
|
||||
Test that default css files served without comprehensive themes applied.
|
||||
"""
|
||||
result = staticfiles.finders.find('css/lms-main-v1.css')
|
||||
self.assertEqual(result, settings.REPO_ROOT / "lms/static/css/lms-main-v1.css")
|
||||
|
||||
lms_main_css = ""
|
||||
with open(result) as css_file:
|
||||
lms_main_css += css_file.read()
|
||||
|
||||
self.assertNotIn("background:#00fa00", lms_main_css)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
|
||||
class TestComprehensiveThemeDisabledCMS(TestCase):
|
||||
"""
|
||||
Test default html, sass and static file when no theme is applied.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Clear static file finders cache and register cleanup methods.
|
||||
"""
|
||||
super(TestComprehensiveThemeDisabledCMS, self).setUp()
|
||||
|
||||
# Clear the internal staticfiles caches, to get test isolation.
|
||||
staticfiles.finders.get_finder.cache_clear()
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""
|
||||
Enable Comprehensive theme and compile sass files.
|
||||
"""
|
||||
# Apply Comprehensive theme and compile sass assets.
|
||||
compile_sass('cms')
|
||||
|
||||
super(TestComprehensiveThemeDisabledCMS, cls).setUpClass()
|
||||
|
||||
def test_template_override(self):
|
||||
"""
|
||||
Test that defaults templates are used when no theme is applied.
|
||||
"""
|
||||
resp = self.client.get('/signin')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotContains(resp, "Login Page override for test-theme.")
|
||||
|
||||
def test_css_files(self):
|
||||
"""
|
||||
Test that default css files served without comprehensive themes applied..
|
||||
"""
|
||||
result = staticfiles.finders.find('css/studio-main-v1.css')
|
||||
self.assertEqual(result, settings.REPO_ROOT / "cms/static/css/studio-main-v1.css")
|
||||
|
||||
cms_main_css = ""
|
||||
with open(result) as css_file:
|
||||
cms_main_css += css_file.read()
|
||||
|
||||
self.assertNotIn("background:#00fa00", cms_main_css)
|
||||
|
||||
|
||||
def compile_sass(system):
|
||||
"""
|
||||
Process xmodule assets and compile sass files for the given system.
|
||||
|
||||
:param system - 'lms' or 'cms', specified the system to compile sass for.
|
||||
"""
|
||||
# Compile system sass files
|
||||
call_task(
|
||||
'pavelib.assets.update_assets',
|
||||
args=(
|
||||
system,
|
||||
"--themes_dir={}".format(settings.TEST_THEME.dirname()),
|
||||
"--themes={}".format(settings.TEST_THEME.basename()),
|
||||
"--settings=test"),
|
||||
)
|
||||
@@ -17,22 +17,3 @@ def cleanup_tempdir(the_dir):
|
||||
"""Called on process exit to remove a temp directory."""
|
||||
if os.path.exists(the_dir):
|
||||
shutil.rmtree(the_dir)
|
||||
|
||||
|
||||
def create_symlink(src, dest):
|
||||
"""
|
||||
Creates a symbolic link which will be deleted when the process ends.
|
||||
:param src: path to source
|
||||
:param dest: path to destination
|
||||
"""
|
||||
os.symlink(src, dest)
|
||||
atexit.register(delete_symlink, dest)
|
||||
|
||||
|
||||
def delete_symlink(link_path):
|
||||
"""
|
||||
Removes symbolic link for
|
||||
:param link_path:
|
||||
"""
|
||||
if os.path.exists(link_path):
|
||||
os.remove(link_path)
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""
|
||||
Django storage backends for Open edX.
|
||||
"""
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage
|
||||
from pipeline.storage import NonPackagingMixin
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage, CachedFilesMixin
|
||||
from pipeline.storage import PipelineMixin, NonPackagingMixin
|
||||
from require.storage import OptimizedFilesMixin
|
||||
from openedx.core.djangoapps.theming.storage import ThemeStorage, ComprehensiveThemingCachedFilesMixin, \
|
||||
ThemePipelineMixin
|
||||
from openedx.core.djangoapps.theming.storage import ComprehensiveThemingAwareMixin
|
||||
|
||||
|
||||
class ProductionStorage(
|
||||
ComprehensiveThemingAwareMixin,
|
||||
OptimizedFilesMixin,
|
||||
ThemePipelineMixin,
|
||||
ComprehensiveThemingCachedFilesMixin,
|
||||
ThemeStorage,
|
||||
PipelineMixin,
|
||||
CachedFilesMixin,
|
||||
StaticFilesStorage
|
||||
):
|
||||
"""
|
||||
@@ -23,9 +22,9 @@ class ProductionStorage(
|
||||
|
||||
|
||||
class DevelopmentStorage(
|
||||
ComprehensiveThemingAwareMixin,
|
||||
NonPackagingMixin,
|
||||
ThemePipelineMixin,
|
||||
ThemeStorage,
|
||||
PipelineMixin,
|
||||
StaticFilesStorage
|
||||
):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user