Commit 68312bdd by Matt Drayer Committed by Saleem Latif

Revert "Revert "saleem-latif/WL-328: Multi-Site Comprehensive Theming""

2. Update COMPREHNSIVE_THEME_DIR to COMPREHENSIVE_THEME_DIRS
3. Update paver commands to support multi theme dirs
4. Updating template loaders
5. Add ENABLE_COMPREHENSIVE_THEMING flag to enable or disable theming via settings
6. Update tests
7. Add backward compatibility for COMPREHEHNSIVE_THEME_DIR
parent d328328f
...@@ -211,7 +211,14 @@ ASSET_IGNORE_REGEX = ENV_TOKENS.get('ASSET_IGNORE_REGEX', ASSET_IGNORE_REGEX) ...@@ -211,7 +211,14 @@ ASSET_IGNORE_REGEX = ENV_TOKENS.get('ASSET_IGNORE_REGEX', ASSET_IGNORE_REGEX)
# Theme overrides # Theme overrides
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None) THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
COMPREHENSIVE_THEME_DIR = path(ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR', COMPREHENSIVE_THEME_DIR))
# following setting is for backward compatibility
if ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR', None):
COMPREHENSIVE_THEME_DIR = ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR')
COMPREHENSIVE_THEME_DIRS = ENV_TOKENS.get('COMPREHENSIVE_THEME_DIRS', COMPREHENSIVE_THEME_DIRS) or []
DEFAULT_SITE_THEME = ENV_TOKENS.get('DEFAULT_SITE_THEME', DEFAULT_SITE_THEME)
ENABLE_COMPREHENSIVE_THEMING = ENV_TOKENS.get('ENABLE_COMPREHENSIVE_THEMING', ENABLE_COMPREHENSIVE_THEMING)
#Timezone overrides #Timezone overrides
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
......
...@@ -59,9 +59,9 @@ STATIC_URL = "/static/" ...@@ -59,9 +59,9 @@ STATIC_URL = "/static/"
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
) )
STATICFILES_DIRS = ( STATICFILES_DIRS = [
(TEST_ROOT / "staticfiles" / "cms").abspath(), (TEST_ROOT / "staticfiles" / "cms").abspath(),
) ]
# Silence noisy logs # Silence noisy logs
import logging import logging
......
...@@ -47,7 +47,7 @@ import lms.envs.common ...@@ -47,7 +47,7 @@ import lms.envs.common
from lms.envs.common import ( from lms.envs.common import (
USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, DATA_DIR, ALL_LANGUAGES, WIKI_ENABLED, USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, DATA_DIR, ALL_LANGUAGES, WIKI_ENABLED,
update_module_store_settings, ASSET_IGNORE_REGEX, COPYRIGHT_YEAR, update_module_store_settings, ASSET_IGNORE_REGEX, COPYRIGHT_YEAR,
PARENTAL_CONSENT_AGE_LIMIT, COMPREHENSIVE_THEME_DIR, REGISTRATION_EMAIL_PATTERNS_ALLOWED, PARENTAL_CONSENT_AGE_LIMIT, COMPREHENSIVE_THEME_DIRS, REGISTRATION_EMAIL_PATTERNS_ALLOWED,
# The following PROFILE_IMAGE_* settings are included as they are # The following PROFILE_IMAGE_* settings are included as they are
# indirectly accessed through the email opt-in API, which is # indirectly accessed through the email opt-in API, which is
# technically accessible through the CMS via legacy URLs. # technically accessible through the CMS via legacy URLs.
...@@ -61,7 +61,20 @@ from lms.envs.common import ( ...@@ -61,7 +61,20 @@ from lms.envs.common import (
# Django REST framework configuration # Django REST framework configuration
REST_FRAMEWORK, REST_FRAMEWORK,
STATICI18N_OUTPUT_DIR STATICI18N_OUTPUT_DIR,
# Theme to use when no site or site theme is defined,
DEFAULT_SITE_THEME,
# Default site to use if no site exists matching request headers
SITE_ID,
# Enable or disable theming
ENABLE_COMPREHENSIVE_THEMING,
# constants for redirects app
REDIRECT_CACHE_TIMEOUT,
REDIRECT_CACHE_KEY_PREFIX,
) )
from path import Path as path from path import Path as path
from warnings import simplefilter from warnings import simplefilter
...@@ -318,6 +331,7 @@ MIDDLEWARE_CLASSES = ( ...@@ -318,6 +331,7 @@ MIDDLEWARE_CLASSES = (
'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.sites.middleware.CurrentSiteMiddleware',
# Instead of SessionMiddleware, we use a more secure version # Instead of SessionMiddleware, we use a more secure version
# 'django.contrib.sessions.middleware.SessionMiddleware', # 'django.contrib.sessions.middleware.SessionMiddleware',
...@@ -356,6 +370,8 @@ MIDDLEWARE_CLASSES = ( ...@@ -356,6 +370,8 @@ MIDDLEWARE_CLASSES = (
# for expiring inactive sessions # for expiring inactive sessions
'session_inactivity_timeout.middleware.SessionInactivityTimeout', 'session_inactivity_timeout.middleware.SessionInactivityTimeout',
'openedx.core.djangoapps.theming.middleware.CurrentSiteThemeMiddleware',
# use Django built in clickjacking protection # use Django built in clickjacking protection
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
) )
...@@ -451,7 +467,6 @@ SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer' ...@@ -451,7 +467,6 @@ SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'
# Site info # Site info
SITE_ID = 1
SITE_NAME = "localhost:8001" SITE_NAME = "localhost:8001"
HTTPS = 'on' HTTPS = 'on'
ROOT_URLCONF = 'cms.urls' ROOT_URLCONF = 'cms.urls'
...@@ -522,7 +537,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage' ...@@ -522,7 +537,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage'
# List of finder classes that know how to find static files in various locations. # List of finder classes that know how to find static files in various locations.
# Note: the pipeline finder is included to be able to discover optimized files # Note: the pipeline finder is included to be able to discover optimized files
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
'openedx.core.djangoapps.theming.finders.ComprehensiveThemeFinder', 'openedx.core.djangoapps.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'openedx.core.lib.xblock_pipeline.finder.XBlockPipelineFinder', 'openedx.core.lib.xblock_pipeline.finder.XBlockPipelineFinder',
......
...@@ -41,7 +41,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.DevelopmentStorage' ...@@ -41,7 +41,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.DevelopmentStorage'
# Revert to the default set of finders as we don't want the production pipeline # Revert to the default set of finders as we don't want the production pipeline
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
'openedx.core.djangoapps.theming.finders.ComprehensiveThemeFinder', 'openedx.core.djangoapps.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
] ]
......
...@@ -41,6 +41,6 @@ STATIC_URL = "/static/" ...@@ -41,6 +41,6 @@ STATIC_URL = "/static/"
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
) )
STATICFILES_DIRS = ( STATICFILES_DIRS = [
(TEST_ROOT / "staticfiles" / "cms").abspath(), (TEST_ROOT / "staticfiles" / "cms").abspath(),
) ]
...@@ -34,6 +34,7 @@ from lms.envs.test import ( ...@@ -34,6 +34,7 @@ from lms.envs.test import (
DEFAULT_FILE_STORAGE, DEFAULT_FILE_STORAGE,
MEDIA_ROOT, MEDIA_ROOT,
MEDIA_URL, MEDIA_URL,
COMPREHENSIVE_THEME_DIRS,
) )
# mongo connection settings # mongo connection settings
...@@ -285,6 +286,8 @@ MICROSITE_CONFIGURATION = { ...@@ -285,6 +286,8 @@ MICROSITE_CONFIGURATION = {
MICROSITE_TEST_HOSTNAME = 'testmicrosite.testserver' MICROSITE_TEST_HOSTNAME = 'testmicrosite.testserver'
MICROSITE_LOGISTRATION_HOSTNAME = 'logistration.testserver' MICROSITE_LOGISTRATION_HOSTNAME = 'logistration.testserver'
TEST_THEME = COMMON_ROOT / "test" / "test-theme"
# For consistency in user-experience, keep the value of this setting in sync with # For consistency in user-experience, keep the value of this setting in sync with
# the one in lms/envs/test.py # the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
......
...@@ -18,7 +18,8 @@ from openedx.core.lib.xblock_utils import xblock_local_resource_url ...@@ -18,7 +18,8 @@ from openedx.core.lib.xblock_utils import xblock_local_resource_url
import xmodule.x_module import xmodule.x_module
import cms.lib.xblock.runtime import cms.lib.xblock.runtime
from openedx.core.djangoapps.theming.core import enable_comprehensive_theme from openedx.core.djangoapps.theming.core import enable_theming
from openedx.core.djangoapps.theming.helpers import is_comprehensive_theming_enabled
def run(): def run():
...@@ -30,8 +31,8 @@ def run(): ...@@ -30,8 +31,8 @@ def run():
# Comprehensive theming needs to be set up before django startup, # Comprehensive theming needs to be set up before django startup,
# because modifying django template paths after startup has no effect. # because modifying django template paths after startup has no effect.
if settings.COMPREHENSIVE_THEME_DIR: if is_comprehensive_theming_enabled():
enable_comprehensive_theme(settings.COMPREHENSIVE_THEME_DIR) enable_theming()
django.setup() django.setup()
......
...@@ -25,6 +25,7 @@ from embargo.test_utils import restrict_course ...@@ -25,6 +25,7 @@ from embargo.test_utils import restrict_course
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import CourseEnrollmentFactory, UserFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
@attr('shard_3') @attr('shard_3')
...@@ -374,7 +375,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -374,7 +375,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
self.assertEquals(course_modes, expected_modes) self.assertEquals(course_modes, expected_modes)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@theming_test_utils.with_is_edx_domain(True) @with_comprehensive_theme("edx.org")
def test_hide_nav(self): def test_hide_nav(self):
# Create the course modes # Create the course modes
for mode in ["honor", "verified"]: for mode in ["honor", "verified"]:
......
...@@ -42,10 +42,14 @@ class MakoLoader(object): ...@@ -42,10 +42,14 @@ class MakoLoader(object):
def load_template(self, template_name, template_dirs=None): def load_template(self, template_name, template_dirs=None):
source, file_path = self.load_template_source(template_name, template_dirs) source, file_path = self.load_template_source(template_name, template_dirs)
# In order to allow dynamic template overrides, we need to cache templates based on their absolute paths
# rather than relative paths, overriding templates would have same relative paths.
module_directory = self.module_directory.rstrip("/") + "/{dir_hash}/".format(dir_hash=hash(file_path))
if source.startswith("## mako\n"): if source.startswith("## mako\n"):
# This is a mako template # This is a mako template
template = Template(filename=file_path, template = Template(filename=file_path,
module_directory=self.module_directory, module_directory=module_directory,
input_encoding='utf-8', input_encoding='utf-8',
output_encoding='utf-8', output_encoding='utf-8',
default_filters=['decode.utf8'], default_filters=['decode.utf8'],
......
...@@ -9,9 +9,14 @@ import pkg_resources ...@@ -9,9 +9,14 @@ import pkg_resources
from django.conf import settings from django.conf import settings
from mako.lookup import TemplateLookup from mako.lookup import TemplateLookup
from mako.exceptions import TopLevelLookupException
from microsite_configuration import microsite
from . import LOOKUP from . import LOOKUP
from openedx.core.djangoapps.theming.helpers import (
get_template as themed_template,
get_template_path_with_theme,
strip_site_theme_templates_path,
)
class DynamicTemplateLookup(TemplateLookup): class DynamicTemplateLookup(TemplateLookup):
...@@ -49,15 +54,29 @@ class DynamicTemplateLookup(TemplateLookup): ...@@ -49,15 +54,29 @@ class DynamicTemplateLookup(TemplateLookup):
def get_template(self, uri): def get_template(self, uri):
""" """
Overridden method which will hand-off the template lookup to the microsite subsystem Overridden method for locating a template in either the database or the site theme.
"""
microsite_template = microsite.get_template(uri)
return ( If not found, template lookup will be done in comprehensive theme for current site
microsite_template by prefixing path to theme.
if microsite_template e.g if uri is `main.html` then new uri would be something like this `/red-theme/lms/static/main.html`
else super(DynamicTemplateLookup, self).get_template(uri)
) If still unable to find a template, it will fallback to the default template directories after stripping off
the prefix path to theme.
"""
# try to get template for the given file from microsite
template = themed_template(uri)
# if microsite template is not present or request is not in microsite then
# let mako find and serve a template
if not template:
try:
# Try to find themed template, i.e. see if current theme overrides the template
template = super(DynamicTemplateLookup, self).get_template(get_template_path_with_theme(uri))
except TopLevelLookupException:
# strip off the prefix path to theme and look in default template dirs
template = super(DynamicTemplateLookup, self).get_template(strip_site_theme_templates_path(uri))
return template
def clear_lookups(namespace): def clear_lookups(namespace):
......
...@@ -12,16 +12,18 @@ ...@@ -12,16 +12,18 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from django.template import Context
from django.http import HttpResponse
import logging import logging
from django.http import HttpResponse
from django.template import Context
from microsite_configuration import microsite from microsite_configuration import microsite
from edxmako import lookup_template from edxmako import lookup_template
from edxmako.request_context import get_template_request_context from edxmako.request_context import get_template_request_context
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from openedx.core.djangoapps.theming.helpers import get_template_path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -134,8 +136,7 @@ def render_to_string(template_name, dictionary, context=None, namespace='main', ...@@ -134,8 +136,7 @@ def render_to_string(template_name, dictionary, context=None, namespace='main',
this template. If not supplied, the current request will be used. this template. If not supplied, the current request will be used.
""" """
# see if there is an override template defined in the microsite template_name = get_template_path(template_name)
template_name = microsite.get_template_path(template_name)
context_instance = Context(dictionary) context_instance = Context(dictionary)
# add dictionary to context_instance # add dictionary to context_instance
......
...@@ -11,7 +11,6 @@ BaseMicrositeTemplateBackend is Base Class for the microsite template backend. ...@@ -11,7 +11,6 @@ BaseMicrositeTemplateBackend is Base Class for the microsite template backend.
from __future__ import absolute_import from __future__ import absolute_import
import abc import abc
import edxmako
import os.path import os.path
import threading import threading
...@@ -272,9 +271,7 @@ class BaseMicrositeBackend(AbstractBaseMicrositeBackend): ...@@ -272,9 +271,7 @@ class BaseMicrositeBackend(AbstractBaseMicrositeBackend):
Configure the paths for the microsites feature Configure the paths for the microsites feature
""" """
microsites_root = settings.MICROSITE_ROOT_DIR microsites_root = settings.MICROSITE_ROOT_DIR
if os.path.isdir(microsites_root): if os.path.isdir(microsites_root):
edxmako.paths.add_lookup('main', microsites_root)
settings.STATICFILES_DIRS.insert(0, microsites_root) settings.STATICFILES_DIRS.insert(0, microsites_root)
log.info('Loading microsite path at %s', microsites_root) log.info('Loading microsite path at %s', microsites_root)
...@@ -292,6 +289,7 @@ class BaseMicrositeBackend(AbstractBaseMicrositeBackend): ...@@ -292,6 +289,7 @@ class BaseMicrositeBackend(AbstractBaseMicrositeBackend):
microsites_root = settings.MICROSITE_ROOT_DIR microsites_root = settings.MICROSITE_ROOT_DIR
if self.has_configuration_set(): if self.has_configuration_set():
settings.MAKO_TEMPLATES['main'].insert(0, microsites_root)
settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].append(microsites_root) settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].append(microsites_root)
......
...@@ -105,6 +105,23 @@ class DatabaseMicrositeBackendTests(DatabaseMicrositeTestCase): ...@@ -105,6 +105,23 @@ class DatabaseMicrositeBackendTests(DatabaseMicrositeTestCase):
microsite.clear() microsite.clear()
self.assertIsNone(microsite.get_value('platform_name')) self.assertIsNone(microsite.get_value('platform_name'))
def test_enable_microsites_pre_startup(self):
"""
Tests microsite.test_enable_microsites_pre_startup works as expected.
"""
# remove microsite root directory paths first
settings.DEFAULT_TEMPLATE_ENGINE['DIRS'] = [
path for path in settings.DEFAULT_TEMPLATE_ENGINE['DIRS']
if path != settings.MICROSITE_ROOT_DIR
]
with patch.dict('django.conf.settings.FEATURES', {'USE_MICROSITES': False}):
microsite.enable_microsites_pre_startup(log)
self.assertNotIn(settings.MICROSITE_ROOT_DIR, settings.DEFAULT_TEMPLATE_ENGINE['DIRS'])
with patch.dict('django.conf.settings.FEATURES', {'USE_MICROSITES': True}):
microsite.enable_microsites_pre_startup(log)
self.assertIn(settings.MICROSITE_ROOT_DIR, settings.DEFAULT_TEMPLATE_ENGINE['DIRS'])
self.assertIn(settings.MICROSITE_ROOT_DIR, settings.MAKO_TEMPLATES['main'])
@patch('edxmako.paths.add_lookup') @patch('edxmako.paths.add_lookup')
def test_enable_microsites(self, add_lookup): def test_enable_microsites(self, add_lookup):
""" """
...@@ -122,7 +139,6 @@ class DatabaseMicrositeBackendTests(DatabaseMicrositeTestCase): ...@@ -122,7 +139,6 @@ class DatabaseMicrositeBackendTests(DatabaseMicrositeTestCase):
with patch.dict('django.conf.settings.FEATURES', {'USE_MICROSITES': True}): with patch.dict('django.conf.settings.FEATURES', {'USE_MICROSITES': True}):
microsite.enable_microsites(log) microsite.enable_microsites(log)
self.assertIn(settings.MICROSITE_ROOT_DIR, settings.STATICFILES_DIRS) self.assertIn(settings.MICROSITE_ROOT_DIR, settings.STATICFILES_DIRS)
add_lookup.assert_called_once_with('main', settings.MICROSITE_ROOT_DIR)
def test_get_all_configs(self): def test_get_all_configs(self):
""" """
......
...@@ -49,7 +49,7 @@ class PipelineRenderTest(TestCase): ...@@ -49,7 +49,7 @@ class PipelineRenderTest(TestCase):
Create static assets once for all pipeline render tests. Create static assets once for all pipeline render tests.
""" """
super(PipelineRenderTest, cls).setUpClass() super(PipelineRenderTest, cls).setUpClass()
call_task('pavelib.assets.update_assets', args=('lms', '--settings=test')) call_task('pavelib.assets.update_assets', args=('lms', '--settings=test', '--themes=no'))
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS') @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')
@ddt.data( @ddt.data(
......
...@@ -20,8 +20,8 @@ from django.conf import settings ...@@ -20,8 +20,8 @@ from django.conf import settings
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from util.request import safe_get_host from util.request import safe_get_host
from util.testing import EventTestMixin from util.testing import EventTestMixin
from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain
from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
class TestException(Exception): class TestException(Exception):
...@@ -99,7 +99,7 @@ class ActivationEmailTests(TestCase): ...@@ -99,7 +99,7 @@ class ActivationEmailTests(TestCase):
self._create_account() self._create_account()
self._assert_activation_email(self.ACTIVATION_SUBJECT, self.OPENEDX_FRAGMENTS) self._assert_activation_email(self.ACTIVATION_SUBJECT, self.OPENEDX_FRAGMENTS)
@with_is_edx_domain(True) @with_comprehensive_theme("edx.org")
def test_activation_email_edx_domain(self): def test_activation_email_edx_domain(self):
self._create_account() self._create_account()
self._assert_activation_email(self.ACTIVATION_SUBJECT, self.EDX_DOMAIN_FRAGMENTS) self._assert_activation_email(self.ACTIVATION_SUBJECT, self.EDX_DOMAIN_FRAGMENTS)
......
...@@ -46,7 +46,6 @@ from certificates.tests.factories import GeneratedCertificateFactory # pylint: ...@@ -46,7 +46,6 @@ from certificates.tests.factories import GeneratedCertificateFactory # pylint:
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
import shoppingcart # pylint: disable=import-error import shoppingcart # pylint: disable=import-error
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain
# Explicitly import the cache from ConfigurationModel so we can reset it after each test # Explicitly import the cache from ConfigurationModel so we can reset it after each test
from config_models.models import cache from config_models.models import cache
...@@ -484,7 +483,6 @@ class DashboardTest(ModuleStoreTestCase): ...@@ -484,7 +483,6 @@ class DashboardTest(ModuleStoreTestCase):
self.assertEquals(response_2.status_code, 200) self.assertEquals(response_2.status_code, 200)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@with_is_edx_domain(True)
def test_dashboard_header_nav_has_find_courses(self): def test_dashboard_header_nav_has_find_courses(self):
self.client.login(username="jack", password="test") self.client.login(username="jack", password="test")
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
......
[
{
"pk": 2,
"model": "sites.Site",
"fields": {
"domain": "localhost:8003",
"name": "lms"
}
},
{
"pk": 3,
"model": "sites.Site",
"fields": {
"domain": "localhost:8031",
"name": "cms"
}
}
]
// studio - utilities - variables
// ====================
// Table of Contents
// * +Paths
// * +Grid
// * +Fonts
// * +Colors - Utility
// * +Colors - Primary
// * +Colors - Shadow
// * +Color - Application
// * +Timing
// * +Archetype UI
// * +Specific UI
// * +Deprecated
$baseline: 20px;
// +Paths
// ====================
$static-path: '..' !default;
// +Grid
// ====================
$gw-column: ($baseline*3);
$gw-gutter: $baseline;
$fg-column: $gw-column;
$fg-gutter: $gw-gutter;
$fg-max-columns: 12;
$fg-max-width: 1280px;
$fg-min-width: 900px;
// +Fonts
// ====================
$f-sans-serif: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif;
$f-monospace: 'Bitstream Vera Sans Mono', Consolas, Courier, monospace;
// +Colors - Utility
// ====================
$transparent: rgba(0,0,0,0); // used when color value is needed for UI width/transitions but element is transparent
// +Colors - Primary
// ====================
$black: rgb(0,0,0);
$black-t0: rgba($black, 0.125);
$black-t1: rgba($black, 0.25);
$black-t2: rgba($black, 0.5);
$black-t3: rgba($black, 0.75);
$white: rgb(255,255,255);
$white-t0: rgba($white, 0.125);
$white-t1: rgba($white, 0.25);
$white-t2: rgba($white, 0.5);
$white-t3: rgba($white, 0.75);
$gray: rgb(127,127,127);
$gray-l1: tint($gray,20%);
$gray-l2: tint($gray,40%);
$gray-l3: tint($gray,60%);
$gray-l4: tint($gray,80%);
$gray-l5: tint($gray,90%);
$gray-l6: tint($gray,95%);
$gray-l7: tint($gray,99%);
$gray-d1: shade($gray,20%);
$gray-d2: shade($gray,40%);
$gray-d3: shade($gray,60%);
$gray-d4: shade($gray,80%);
$blue: rgb(0, 159, 230);
$blue-l1: tint($blue,20%);
$blue-l2: tint($blue,40%);
$blue-l3: tint($blue,60%);
$blue-l4: tint($blue,80%);
$blue-l5: tint($blue,90%);
$blue-d1: shade($blue,20%);
$blue-d2: shade($blue,40%);
$blue-d3: shade($blue,60%);
$blue-d4: shade($blue,80%);
$blue-s1: saturate($blue,15%);
$blue-s2: saturate($blue,30%);
$blue-s3: saturate($blue,45%);
$blue-u1: desaturate($blue,15%);
$blue-u2: desaturate($blue,30%);
$blue-u3: desaturate($blue,45%);
$blue-t0: rgba($blue, 0.125);
$blue-t1: rgba($blue, 0.25);
$blue-t2: rgba($blue, 0.50);
$blue-t3: rgba($blue, 0.75);
$pink: rgb(183, 37, 103); // #b72567;
$pink-l1: tint($pink,20%);
$pink-l2: tint($pink,40%);
$pink-l3: tint($pink,60%);
$pink-l4: tint($pink,80%);
$pink-l5: tint($pink,90%);
$pink-d1: shade($pink,20%);
$pink-d2: shade($pink,40%);
$pink-d3: shade($pink,60%);
$pink-d4: shade($pink,80%);
$pink-s1: saturate($pink,15%);
$pink-s2: saturate($pink,30%);
$pink-s3: saturate($pink,45%);
$pink-u1: desaturate($pink,15%);
$pink-u2: desaturate($pink,30%);
$pink-u3: desaturate($pink,45%);
$red: rgb(178, 6, 16); // #b20610;
$red-l1: tint($red,20%);
$red-l2: tint($red,40%);
$red-l3: tint($red,60%);
$red-l4: tint($red,80%);
$red-l5: tint($red,90%);
$red-d1: shade($red,20%);
$red-d2: shade($red,40%);
$red-d3: shade($red,60%);
$red-d4: shade($red,80%);
$red-s1: saturate($red,15%);
$red-s2: saturate($red,30%);
$red-s3: saturate($red,45%);
$red-u1: desaturate($red,15%);
$red-u2: desaturate($red,30%);
$red-u3: desaturate($red,45%);
$green: rgb(37, 184, 90); // #25b85a
$green-l1: tint($green,20%);
$green-l2: tint($green,40%);
$green-l3: tint($green,60%);
$green-l4: tint($green,80%);
$green-l5: tint($green,90%);
$green-d1: shade($green,20%);
$green-d2: shade($green,40%);
$green-d3: shade($green,60%);
$green-d4: shade($green,80%);
$green-s1: saturate($green,15%);
$green-s2: saturate($green,30%);
$green-s3: saturate($green,45%);
$green-u1: desaturate($green,15%);
$green-u2: desaturate($green,30%);
$green-u3: desaturate($green,45%);
$yellow: rgb(237, 189, 60);
$yellow-l1: tint($yellow,20%);
$yellow-l2: tint($yellow,40%);
$yellow-l3: tint($yellow,60%);
$yellow-l4: tint($yellow,80%);
$yellow-l5: tint($yellow,90%);
$yellow-d1: shade($yellow,20%);
$yellow-d2: shade($yellow,40%);
$yellow-d3: shade($yellow,60%);
$yellow-d4: shade($yellow,80%);
$yellow-s1: saturate($yellow,15%);
$yellow-s2: saturate($yellow,30%);
$yellow-s3: saturate($yellow,45%);
$yellow-u1: desaturate($yellow,15%);
$yellow-u2: desaturate($yellow,30%);
$yellow-u3: desaturate($yellow,45%);
$orange: rgb(237, 189, 60);
$orange-l1: tint($orange,20%);
$orange-l2: tint($orange,40%);
$orange-l3: tint($orange,60%);
$orange-l4: tint($orange,80%);
$orange-l5: tint($orange,90%);
$orange-d1: shade($orange,20%);
$orange-d2: shade($orange,40%);
$orange-d3: shade($orange,60%);
$orange-d4: shade($orange,80%);
$orange-s1: saturate($orange,15%);
$orange-s2: saturate($orange,30%);
$orange-s3: saturate($orange,45%);
$orange-u1: desaturate($orange,15%);
$orange-u2: desaturate($orange,30%);
$orange-u3: desaturate($orange,45%);
// +Colors - Shadows
// ====================
$shadow: rgba($black, 0.2);
$shadow-l1: rgba($black, 0.1);
$shadow-l2: rgba($black, 0.05);
$shadow-d1: rgba($black, 0.4);
$shadow-d2: rgba($black, 0.6);
// +Colors - Application
// ====================
$color-draft: $gray-l3;
$color-live: $blue;
$color-ready: $green;
$color-warning: $orange-l2;
$color-error: $red-l2;
$color-staff-only: $black;
$color-gated: $black;
$color-visibility-set: $black;
$color-heading-base: $gray-d2;
$color-copy-base: $gray-l1;
$color-copy-emphasized: $gray-d2;
// +Timing
// ====================
// used for animation/transition mixin syncing
$tmg-s3: 3.0s;
$tmg-s2: 2.0s;
$tmg-s1: 1.0s;
$tmg-avg: 0.75s;
$tmg-f1: 0.50s;
$tmg-f2: 0.25s;
$tmg-f3: 0.125s;
// +Archetype UI
// ====================
$ui-action-primary-color: $blue-u2;
$ui-action-primary-color-focus: $blue-s1;
$ui-link-color: $blue-u2;
$ui-link-color-focus: $blue-s1;
// +Specific UI
// ====================
$ui-notification-height: ($baseline*10);
$ui-update-color: $blue-l4;
// +Deprecated
// ====================
// do not use, future clean up will use updated styles
$baseFontColor: $gray-d2;
$lighter-base-font-color: rgb(100,100,100);
$offBlack: #3c3c3c;
$green: #108614;
$lightGrey: #edf1f5;
$mediumGrey: #b0b6c2;
$darkGrey: #8891a1;
$extraDarkGrey: #3d4043;
$paleYellow: #fffcf1;
$yellow: rgb(255, 254, 223);
$green: rgb(37, 184, 90);
$brightGreen: rgb(22, 202, 87);
$disabledGreen: rgb(124, 206, 153);
$darkGreen: rgb(52, 133, 76);
// These colors are updated for testing purposes
$lightBluishGrey: rgb(0, 250, 0);
$lightBluishGrey2: rgb(0, 250, 0);
$error-red: rgb(253, 87, 87);
//carryover from LMS for xmodules
$sidebar-color: rgb(246, 246, 246);
// type
$sans-serif: $f-sans-serif;
$body-line-height: golden-ratio(.875em, 1);
// carried over from LMS for xmodules
$action-primary-active-bg: #1AA1DE; // $m-blue
$very-light-text: $white;
<%page expression_filter="h"/>
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "login" %></%def>
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from openedx.core.djangolib.js_utils import js_escaped_string
%>
<%block name="title">${_("Sign In")}</%block>
<%block name="bodyclass">not-signedin view-signin</%block>
<%block name="content">
<div class="wrapper-content wrapper">
<section class="content">
<header>
<h1 class="title title-1">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</h1>
<a href="${reverse('signup')}" class="action action-signin">${_("Don't have a {studio_name} Account? Sign up!").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
</header>
<!-- Login Page override for test-theme. -->
<article class="content-primary" role="main">
<form id="login_form" method="post" action="login_post" onsubmit="return false;">
<fieldset>
<legend class="sr">${_("Required Information to Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</legend>
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf }" />
<ol class="list-input">
<li class="field text required" id="field-email">
<label for="email">${_("E-mail")}</label>
<input id="email" type="email" name="email" placeholder="${_('example: username@domain.com')}"/>
</li>
<li class="field text required" id="field-password">
<label for="password">${_("Password")}</label>
<input id="password" type="password" name="password" />
<a href="${forgot_password_link}" class="action action-forgotpassword">${_("Forgot password?")}</a>
</li>
</ol>
</fieldset>
<div class="form-actions">
<button type="submit" id="submit" name="submit" class="action action-primary">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</button>
</div>
<!-- no honor code for CMS, but need it because we're using the lms student object -->
<input name="honor_code" type="checkbox" value="true" checked="true" hidden="true">
</form>
</article>
</section>
</div>
</%block>
<%block name="requirejs">
require(["js/factories/login"], function(LoginFactory) {
LoginFactory("${reverse('homepage') | n, js_escaped_string }");
});
</%block>
@import 'lms/static/sass/partials/base/variables';
$header-bg: rgb(0,250,0);
$footer-bg: rgb(0,250,0);
$container-bg: rgb(0,250,0);
<%page expression_filter="h"/>
<div class="wrapper wrapper-footer">
<footer>
<div class="colophon">
<div class="colophon-about">
<p>This is a footer for test-theme.</p>
</div>
</div>
</footer>
</div>
...@@ -10,7 +10,7 @@ import mock ...@@ -10,7 +10,7 @@ import mock
import ddt import ddt
from config_models.models import cache from config_models.models import cache
from branding.models import BrandingApiConfig from branding.models import BrandingApiConfig
from openedx.core.djangoapps.theming.tests.test_util import with_edx_domain_context from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context
@ddt.ddt @ddt.ddt
...@@ -30,19 +30,19 @@ class TestFooter(TestCase): ...@@ -30,19 +30,19 @@ class TestFooter(TestCase):
@ddt.data( @ddt.data(
# Open source version # Open source version
(False, "application/json", "application/json; charset=utf-8", "Open edX"), (None, "application/json", "application/json; charset=utf-8", "Open edX"),
(False, "text/html", "text/html; charset=utf-8", "lms-footer.css"), (None, "text/html", "text/html; charset=utf-8", "lms-footer.css"),
(False, "text/html", "text/html; charset=utf-8", "Open edX"), (None, "text/html", "text/html; charset=utf-8", "Open edX"),
# EdX.org version # EdX.org version
(True, "application/json", "application/json; charset=utf-8", "edX Inc"), ("edx.org", "application/json", "application/json; charset=utf-8", "edX Inc"),
(True, "text/html", "text/html; charset=utf-8", "lms-footer-edx.css"), ("edx.org", "text/html", "text/html; charset=utf-8", "lms-footer-edx.css"),
(True, "text/html", "text/html; charset=utf-8", "edX Inc"), ("edx.org", "text/html", "text/html; charset=utf-8", "edX Inc"),
) )
@ddt.unpack @ddt.unpack
def test_footer_content_types(self, is_edx_domain, accepts, content_type, content): def test_footer_content_types(self, theme, accepts, content_type, content):
self._set_feature_flag(True) self._set_feature_flag(True)
with with_edx_domain_context(is_edx_domain): with with_comprehensive_theme_context(theme):
resp = self._get_footer(accepts=accepts) resp = self._get_footer(accepts=accepts)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
...@@ -50,10 +50,10 @@ class TestFooter(TestCase): ...@@ -50,10 +50,10 @@ class TestFooter(TestCase):
self.assertIn(content, resp.content) self.assertIn(content, resp.content)
@mock.patch.dict(settings.FEATURES, {'ENABLE_FOOTER_MOBILE_APP_LINKS': True}) @mock.patch.dict(settings.FEATURES, {'ENABLE_FOOTER_MOBILE_APP_LINKS': True})
@ddt.data(True, False) @ddt.data("edx.org", None)
def test_footer_json(self, is_edx_domain): def test_footer_json(self, theme):
self._set_feature_flag(True) self._set_feature_flag(True)
with with_edx_domain_context(is_edx_domain): with with_comprehensive_theme_context(theme):
resp = self._get_footer() resp = self._get_footer()
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
...@@ -142,18 +142,18 @@ class TestFooter(TestCase): ...@@ -142,18 +142,18 @@ class TestFooter(TestCase):
@ddt.data( @ddt.data(
# OpenEdX # OpenEdX
(False, "en", "lms-footer.css"), (None, "en", "lms-footer.css"),
(False, "ar", "lms-footer-rtl.css"), (None, "ar", "lms-footer-rtl.css"),
# EdX.org # EdX.org
(True, "en", "lms-footer-edx.css"), ("edx.org", "en", "lms-footer-edx.css"),
(True, "ar", "lms-footer-edx-rtl.css"), ("edx.org", "ar", "lms-footer-edx-rtl.css"),
) )
@ddt.unpack @ddt.unpack
def test_language_rtl(self, is_edx_domain, language, static_path): def test_language_rtl(self, theme, language, static_path):
self._set_feature_flag(True) self._set_feature_flag(True)
with with_edx_domain_context(is_edx_domain): with with_comprehensive_theme_context(theme):
resp = self._get_footer(accepts="text/html", params={'language': language}) resp = self._get_footer(accepts="text/html", params={'language': language})
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
...@@ -161,18 +161,18 @@ class TestFooter(TestCase): ...@@ -161,18 +161,18 @@ class TestFooter(TestCase):
@ddt.data( @ddt.data(
# OpenEdX # OpenEdX
(False, True), (None, True),
(False, False), (None, False),
# EdX.org # EdX.org
(True, True), ("edx.org", True),
(True, False), ("edx.org", False),
) )
@ddt.unpack @ddt.unpack
def test_show_openedx_logo(self, is_edx_domain, show_logo): def test_show_openedx_logo(self, theme, show_logo):
self._set_feature_flag(True) self._set_feature_flag(True)
with with_edx_domain_context(is_edx_domain): with with_comprehensive_theme_context(theme):
params = {'show-openedx-logo': 1} if show_logo else {} params = {'show-openedx-logo': 1} if show_logo else {}
resp = self._get_footer(accepts="text/html", params=params) resp = self._get_footer(accepts="text/html", params=params)
...@@ -185,17 +185,17 @@ class TestFooter(TestCase): ...@@ -185,17 +185,17 @@ class TestFooter(TestCase):
@ddt.data( @ddt.data(
# OpenEdX # OpenEdX
(False, False), (None, False),
(False, True), (None, True),
# EdX.org # EdX.org
(True, False), ("edx.org", False),
(True, True), ("edx.org", True),
) )
@ddt.unpack @ddt.unpack
def test_include_dependencies(self, is_edx_domain, include_dependencies): def test_include_dependencies(self, theme, include_dependencies):
self._set_feature_flag(True) self._set_feature_flag(True)
with with_edx_domain_context(is_edx_domain): with with_comprehensive_theme_context(theme):
params = {'include-dependencies': 1} if include_dependencies else {} params = {'include-dependencies': 1} if include_dependencies else {}
resp = self._get_footer(accepts="text/html", params=params) resp = self._get_footer(accepts="text/html", params=params)
......
...@@ -8,7 +8,7 @@ from django.test import TestCase ...@@ -8,7 +8,7 @@ from django.test import TestCase
import mock import mock
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
class UserMixin(object): class UserMixin(object):
...@@ -85,7 +85,7 @@ class ReceiptViewTests(UserMixin, TestCase): ...@@ -85,7 +85,7 @@ class ReceiptViewTests(UserMixin, TestCase):
self.assertRegexpMatches(response.content, user_message if is_user_message_expected else system_message) self.assertRegexpMatches(response.content, user_message if is_user_message_expected else system_message)
self.assertNotRegexpMatches(response.content, user_message if not is_user_message_expected else system_message) self.assertNotRegexpMatches(response.content, user_message if not is_user_message_expected else system_message)
@with_is_edx_domain(True) @with_comprehensive_theme("edx.org")
def test_hide_nav_header(self): def test_hide_nav_header(self):
self._login() self._login()
post_data = {'decision': 'ACCEPT', 'reason_code': '200', 'signed_field_names': 'dummy'} post_data = {'decision': 'ACCEPT', 'reason_code': '200', 'signed_field_names': 'dummy'}
......
""" """
Tests for wiki middleware. Tests for wiki middleware.
""" """
from django.conf import settings
from django.test.client import Client from django.test.client import Client
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from unittest import skip from unittest import skip
...@@ -35,7 +34,7 @@ class TestComprehensiveTheming(ModuleStoreTestCase): ...@@ -35,7 +34,7 @@ class TestComprehensiveTheming(ModuleStoreTestCase):
self.client.login(username='instructor', password='secret') self.client.login(username='instructor', password='secret')
@skip("Fails when run immediately after lms.djangoapps.course_wiki.tests.test_middleware") @skip("Fails when run immediately after lms.djangoapps.course_wiki.tests.test_middleware")
@with_comprehensive_theme(settings.REPO_ROOT / 'themes/red-theme') @with_comprehensive_theme('red-theme')
def test_themed_footer(self): def test_themed_footer(self):
""" """
Tests that theme footer is used rather than standard Tests that theme footer is used rather than standard
......
...@@ -6,8 +6,6 @@ import re ...@@ -6,8 +6,6 @@ import re
import cgi import cgi
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -51,21 +49,6 @@ def course_wiki_redirect(request, course_id): # pylint: disable=unused-argument ...@@ -51,21 +49,6 @@ def course_wiki_redirect(request, course_id): # pylint: disable=unused-argument
if not valid_slug: if not valid_slug:
return redirect("wiki:get", path="") return redirect("wiki:get", path="")
# The wiki needs a Site object created. We make sure it exists here
try:
Site.objects.get_current()
except Site.DoesNotExist:
new_site = Site()
new_site.domain = settings.SITE_NAME
new_site.name = "edX"
new_site.save()
site_id = str(new_site.id)
if site_id != str(settings.SITE_ID):
msg = "No site object was created and the SITE_ID doesn't match the newly created one. {} != {}".format(
site_id, settings.SITE_ID
)
raise ImproperlyConfigured(msg)
try: try:
urlpath = URLPath.get_by_path(course_slug, select_related=True) urlpath = URLPath.get_by_path(course_slug, select_related=True)
......
...@@ -7,7 +7,7 @@ from path import path # pylint: disable=no-name-in-module ...@@ -7,7 +7,7 @@ from path import path # pylint: disable=no-name-in-module
from django.contrib import staticfiles from django.contrib import staticfiles
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
from openedx.core.lib.tempdir import mkdtemp_clean from openedx.core.lib.tempdir import mkdtemp_clean, create_symlink, delete_symlink
class TestComprehensiveTheming(TestCase): class TestComprehensiveTheming(TestCase):
...@@ -19,8 +19,13 @@ class TestComprehensiveTheming(TestCase): ...@@ -19,8 +19,13 @@ class TestComprehensiveTheming(TestCase):
# Clear the internal staticfiles caches, to get test isolation. # Clear the internal staticfiles caches, to get test isolation.
staticfiles.finders.get_finder.cache_clear() staticfiles.finders.get_finder.cache_clear()
@with_comprehensive_theme(settings.REPO_ROOT / 'themes/red-theme') @with_comprehensive_theme('red-theme')
def test_red_footer(self): def test_red_footer(self):
"""
Tests templates from theme are rendered if available.
`red-theme` has header.html and footer.html so this test
asserts presence of the content from header.html and footer.html
"""
resp = self.client.get('/') resp = self.client.get('/')
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
# This string comes from footer.html # This string comes from footer.html
...@@ -34,12 +39,16 @@ class TestComprehensiveTheming(TestCase): ...@@ -34,12 +39,16 @@ class TestComprehensiveTheming(TestCase):
# of test. # of test.
# Make a temp directory as a theme. # Make a temp directory as a theme.
tmp_theme = path(mkdtemp_clean()) themes_dir = path(mkdtemp_clean())
template_dir = tmp_theme / "lms/templates" tmp_theme = "temp_theme"
template_dir = themes_dir / tmp_theme / "lms/templates"
template_dir.makedirs() template_dir.makedirs()
with open(template_dir / "footer.html", "w") as footer: with open(template_dir / "footer.html", "w") as footer:
footer.write("<footer>TEMPORARY THEME</footer>") footer.write("<footer>TEMPORARY THEME</footer>")
dest_path = path(settings.COMPREHENSIVE_THEME_DIRS[0]) / tmp_theme
create_symlink(themes_dir / tmp_theme, dest_path)
@with_comprehensive_theme(tmp_theme) @with_comprehensive_theme(tmp_theme)
def do_the_test(self): def do_the_test(self):
"""A function to do the work so we can use the decorator.""" """A function to do the work so we can use the decorator."""
...@@ -48,28 +57,16 @@ class TestComprehensiveTheming(TestCase): ...@@ -48,28 +57,16 @@ class TestComprehensiveTheming(TestCase):
self.assertContains(resp, "TEMPORARY THEME") self.assertContains(resp, "TEMPORARY THEME")
do_the_test(self) do_the_test(self)
# remove symlinks before running subsequent tests
def test_theme_adjusts_staticfiles_search_path(self): delete_symlink(dest_path)
# Test that a theme adds itself to the staticfiles search path.
before_finders = list(settings.STATICFILES_FINDERS)
before_dirs = list(settings.STATICFILES_DIRS)
@with_comprehensive_theme(settings.REPO_ROOT / 'themes/red-theme')
def do_the_test(self):
"""A function to do the work so we can use the decorator."""
self.assertEqual(list(settings.STATICFILES_FINDERS), before_finders)
self.assertEqual(settings.STATICFILES_DIRS[0], settings.REPO_ROOT / 'themes/red-theme/lms/static')
self.assertEqual(settings.STATICFILES_DIRS[1:], before_dirs)
do_the_test(self)
def test_default_logo_image(self): def test_default_logo_image(self):
result = staticfiles.finders.find('images/logo.png') result = staticfiles.finders.find('images/logo.png')
self.assertEqual(result, settings.REPO_ROOT / 'lms/static/images/logo.png') self.assertEqual(result, settings.REPO_ROOT / 'lms/static/images/logo.png')
@with_comprehensive_theme(settings.REPO_ROOT / 'themes/red-theme') @with_comprehensive_theme('red-theme')
def test_overridden_logo_image(self): def test_overridden_logo_image(self):
result = staticfiles.finders.find('images/logo.png') result = staticfiles.finders.find('red-theme/images/logo.png')
self.assertEqual(result, settings.REPO_ROOT / 'themes/red-theme/lms/static/images/logo.png') self.assertEqual(result, settings.REPO_ROOT / 'themes/red-theme/lms/static/images/logo.png')
def test_default_favicon(self): def test_default_favicon(self):
...@@ -79,10 +76,10 @@ class TestComprehensiveTheming(TestCase): ...@@ -79,10 +76,10 @@ class TestComprehensiveTheming(TestCase):
result = staticfiles.finders.find('images/favicon.ico') result = staticfiles.finders.find('images/favicon.ico')
self.assertEqual(result, settings.REPO_ROOT / 'lms/static/images/favicon.ico') self.assertEqual(result, settings.REPO_ROOT / 'lms/static/images/favicon.ico')
@with_comprehensive_theme(settings.REPO_ROOT / 'themes/red-theme') @with_comprehensive_theme('red-theme')
def test_overridden_favicon(self): def test_overridden_favicon(self):
""" """
Test comprehensive theme override on favicon image. Test comprehensive theme override on favicon image.
""" """
result = staticfiles.finders.find('images/favicon.ico') result = staticfiles.finders.find('red-theme/images/favicon.ico')
self.assertEqual(result, settings.REPO_ROOT / 'themes/red-theme/lms/static/images/favicon.ico') self.assertEqual(result, settings.REPO_ROOT / 'themes/red-theme/lms/static/images/favicon.ico')
...@@ -312,11 +312,12 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest ...@@ -312,11 +312,12 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest
url = reverse('info', args=[unicode(course.id)]) url = reverse('info', args=[unicode(course.id)])
with self.assertNumQueries(sql_queries): with self.assertNumQueries(sql_queries):
with check_mongo_calls(mongo_queries): with check_mongo_calls(mongo_queries):
resp = self.client.get(url) with mock.patch("openedx.core.djangoapps.theming.helpers.get_current_site", return_value=None):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
def test_num_queries_instructor_paced(self): def test_num_queries_instructor_paced(self):
self.fetch_course_info_with_queries(self.instructor_paced_course, 21, 4) self.fetch_course_info_with_queries(self.instructor_paced_course, 22, 4)
def test_num_queries_self_paced(self): def test_num_queries_self_paced(self):
self.fetch_course_info_with_queries(self.self_paced_course, 21, 4) self.fetch_course_info_with_queries(self.self_paced_course, 22, 4)
...@@ -3,17 +3,22 @@ Tests related to the basic footer-switching based off SITE_NAME to ensure ...@@ -3,17 +3,22 @@ Tests related to the basic footer-switching based off SITE_NAME to ensure
edx.org uses an edx footer but other instances use an Open edX footer. edx.org uses an edx footer but other instances use an Open edX footer.
""" """
import unittest
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@attr('shard_1') @attr('shard_1')
class TestFooter(TestCase): class TestFooter(TestCase):
"""
Tests for edx and OpenEdX footer
"""
SOCIAL_MEDIA_NAMES = [ SOCIAL_MEDIA_NAMES = [
"facebook", "facebook",
...@@ -37,7 +42,7 @@ class TestFooter(TestCase): ...@@ -37,7 +42,7 @@ class TestFooter(TestCase):
"youtube": "https://www.youtube.com/" "youtube": "https://www.youtube.com/"
} }
@with_is_edx_domain(True) @with_comprehensive_theme("edx.org")
def test_edx_footer(self): def test_edx_footer(self):
""" """
Verify that the homepage, when accessed at edx.org, has the edX footer Verify that the homepage, when accessed at edx.org, has the edX footer
...@@ -46,7 +51,6 @@ class TestFooter(TestCase): ...@@ -46,7 +51,6 @@ class TestFooter(TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'footer-edx-v3') self.assertContains(resp, 'footer-edx-v3')
@with_is_edx_domain(False)
def test_openedx_footer(self): def test_openedx_footer(self):
""" """
Verify that the homepage, when accessed at something other than Verify that the homepage, when accessed at something other than
...@@ -56,7 +60,7 @@ class TestFooter(TestCase): ...@@ -56,7 +60,7 @@ class TestFooter(TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'footer-openedx') self.assertContains(resp, 'footer-openedx')
@with_is_edx_domain(True) @with_comprehensive_theme("edx.org")
@override_settings( @override_settings(
SOCIAL_MEDIA_FOOTER_NAMES=SOCIAL_MEDIA_NAMES, SOCIAL_MEDIA_FOOTER_NAMES=SOCIAL_MEDIA_NAMES,
SOCIAL_MEDIA_FOOTER_URLS=SOCIAL_MEDIA_URLS SOCIAL_MEDIA_FOOTER_URLS=SOCIAL_MEDIA_URLS
......
...@@ -1340,7 +1340,7 @@ class ProgressPageTests(ModuleStoreTestCase): ...@@ -1340,7 +1340,7 @@ class ProgressPageTests(ModuleStoreTestCase):
self.assertContains(resp, u"Download Your Certificate") self.assertContains(resp, u"Download Your Certificate")
@ddt.data( @ddt.data(
*itertools.product(((46, 4, True), (46, 4, False)), (True, False)) *itertools.product(((47, 4, True), (47, 4, False)), (True, False))
) )
@ddt.unpack @ddt.unpack
def test_query_counts(self, (sql_calls, mongo_calls, self_paced), self_paced_enabled): def test_query_counts(self, (sql_calls, mongo_calls, self_paced), self_paced_enabled):
......
...@@ -372,8 +372,8 @@ class ViewsQueryCountTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet ...@@ -372,8 +372,8 @@ class ViewsQueryCountTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet
return inner return inner
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 4, 30), (ModuleStoreEnum.Type.mongo, 3, 4, 31),
(ModuleStoreEnum.Type.split, 3, 13, 30), (ModuleStoreEnum.Type.split, 3, 13, 31),
) )
@ddt.unpack @ddt.unpack
@count_queries @count_queries
...@@ -381,8 +381,8 @@ class ViewsQueryCountTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet ...@@ -381,8 +381,8 @@ class ViewsQueryCountTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet
self.create_thread_helper(mock_request) self.create_thread_helper(mock_request)
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 3, 24), (ModuleStoreEnum.Type.mongo, 3, 3, 25),
(ModuleStoreEnum.Type.split, 3, 10, 24), (ModuleStoreEnum.Type.split, 3, 10, 25),
) )
@ddt.unpack @ddt.unpack
@count_queries @count_queries
......
...@@ -29,12 +29,12 @@ from openedx.core.djangoapps.user_api.accounts.api import activate_account, crea ...@@ -29,12 +29,12 @@ from openedx.core.djangoapps.user_api.accounts.api import activate_account, crea
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH
from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.djangolib.js_utils import dump_js_escaped_json
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from openedx.core.djangoapps.theming.tests.test_util import with_edx_domain_context
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student_account.views import account_settings_context, get_user_orders from student_account.views import account_settings_context, get_user_orders
from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context
@ddt.ddt @ddt.ddt
...@@ -262,13 +262,13 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -262,13 +262,13 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
self.assertRedirects(response, reverse("dashboard")) self.assertRedirects(response, reverse("dashboard"))
@ddt.data( @ddt.data(
(False, "signin_user"), (None, "signin_user"),
(False, "register_user"), (None, "register_user"),
(True, "signin_user"), ("edx.org", "signin_user"),
(True, "register_user"), ("edx.org", "register_user"),
) )
@ddt.unpack @ddt.unpack
def test_login_and_registration_form_signin_preserves_params(self, is_edx_domain, url_name): def test_login_and_registration_form_signin_preserves_params(self, theme, url_name):
params = [ params = [
('course_id', 'edX/DemoX/Demo_Course'), ('course_id', 'edX/DemoX/Demo_Course'),
('enrollment_action', 'enroll'), ('enrollment_action', 'enroll'),
...@@ -276,7 +276,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -276,7 +276,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
# The response should have a "Sign In" button with the URL # The response should have a "Sign In" button with the URL
# that preserves the querystring params # that preserves the querystring params
with with_edx_domain_context(is_edx_domain): with with_comprehensive_theme_context(theme):
response = self.client.get(reverse(url_name), params) response = self.client.get(reverse(url_name), params)
expected_url = '/login?{}'.format(self._finish_auth_url_param(params + [('next', '/dashboard')])) expected_url = '/login?{}'.format(self._finish_auth_url_param(params + [('next', '/dashboard')]))
...@@ -292,7 +292,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -292,7 +292,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
] ]
# Verify that this parameter is also preserved # Verify that this parameter is also preserved
with with_edx_domain_context(is_edx_domain): with with_comprehensive_theme_context(theme):
response = self.client.get(reverse(url_name), params) response = self.client.get(reverse(url_name), params)
expected_url = '/login?{}'.format(self._finish_auth_url_param(params)) expected_url = '/login?{}'.format(self._finish_auth_url_param(params))
......
...@@ -39,7 +39,7 @@ from commerce.models import CommerceConfiguration ...@@ -39,7 +39,7 @@ from commerce.models import CommerceConfiguration
from commerce.tests import TEST_PAYMENT_DATA, TEST_API_URL, TEST_API_SIGNING_KEY, TEST_PUBLIC_URL_ROOT from commerce.tests import TEST_PAYMENT_DATA, TEST_API_URL, TEST_API_SIGNING_KEY, TEST_PUBLIC_URL_ROOT
from embargo.test_utils import restrict_course from embargo.test_utils import restrict_course
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
from shoppingcart.models import Order, CertificateItem from shoppingcart.models import Order, CertificateItem
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -321,7 +321,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin): ...@@ -321,7 +321,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin):
) )
self._assert_redirects_to_dashboard(response) self._assert_redirects_to_dashboard(response)
@with_is_edx_domain(True) @with_comprehensive_theme("edx.org")
@ddt.data("verify_student_start_flow", "verify_student_begin_flow") @ddt.data("verify_student_start_flow", "verify_student_begin_flow")
def test_pay_and_verify_hides_header_nav(self, payment_flow): def test_pay_and_verify_hides_header_nav(self, payment_flow):
course = self._create_course("verified") course = self._create_course("verified")
......
...@@ -260,7 +260,14 @@ BULK_EMAIL_ROUTING_KEY_SMALL_JOBS = LOW_PRIORITY_QUEUE ...@@ -260,7 +260,14 @@ BULK_EMAIL_ROUTING_KEY_SMALL_JOBS = LOW_PRIORITY_QUEUE
# Theme overrides # Theme overrides
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None) THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
COMPREHENSIVE_THEME_DIR = path(ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR', COMPREHENSIVE_THEME_DIR))
# following setting is for backward compatibility
if ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR', None):
COMPREHENSIVE_THEME_DIR = ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR')
COMPREHENSIVE_THEME_DIRS = ENV_TOKENS.get('COMPREHENSIVE_THEME_DIRS', COMPREHENSIVE_THEME_DIRS) or []
DEFAULT_SITE_THEME = ENV_TOKENS.get('DEFAULT_SITE_THEME', DEFAULT_SITE_THEME)
ENABLE_COMPREHENSIVE_THEMING = ENV_TOKENS.get('ENABLE_COMPREHENSIVE_THEMING', ENABLE_COMPREHENSIVE_THEMING)
# Marketing link overrides # Marketing link overrides
MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {})) MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {}))
......
...@@ -61,9 +61,9 @@ STATIC_URL = "/static/" ...@@ -61,9 +61,9 @@ STATIC_URL = "/static/"
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
) )
STATICFILES_DIRS = ( STATICFILES_DIRS = [
(TEST_ROOT / "staticfiles" / "lms").abspath(), (TEST_ROOT / "staticfiles" / "lms").abspath(),
) ]
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
MEDIA_ROOT = TEST_ROOT / "uploads" MEDIA_ROOT = TEST_ROOT / "uploads"
......
...@@ -387,9 +387,6 @@ COURSES_ROOT = ENV_ROOT / "data" ...@@ -387,9 +387,6 @@ COURSES_ROOT = ENV_ROOT / "data"
DATA_DIR = COURSES_ROOT DATA_DIR = COURSES_ROOT
# comprehensive theming system
COMPREHENSIVE_THEME_DIR = ""
# TODO: Remove the rest of the sys.path modification here and in cms/envs/common.py # TODO: Remove the rest of the sys.path modification here and in cms/envs/common.py
sys.path.append(REPO_ROOT) sys.path.append(REPO_ROOT)
sys.path.append(PROJECT_ROOT / 'djangoapps') sys.path.append(PROJECT_ROOT / 'djangoapps')
...@@ -489,6 +486,7 @@ TEMPLATES = [ ...@@ -489,6 +486,7 @@ TEMPLATES = [
'loaders': [ 'loaders': [
# We have to use mako-aware template loaders to be able to include # We have to use mako-aware template loaders to be able to include
# mako templates inside django templates (such as main_django.html). # mako templates inside django templates (such as main_django.html).
'openedx.core.djangoapps.theming.template_loaders.ThemeTemplateLoader',
'edxmako.makoloader.MakoFilesystemLoader', 'edxmako.makoloader.MakoFilesystemLoader',
'edxmako.makoloader.MakoAppDirectoriesLoader', 'edxmako.makoloader.MakoAppDirectoriesLoader',
], ],
...@@ -784,7 +782,6 @@ SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer' ...@@ -784,7 +782,6 @@ SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'
CMS_BASE = 'localhost:8001' CMS_BASE = 'localhost:8001'
# Site info # Site info
SITE_ID = 1
SITE_NAME = "example.com" SITE_NAME = "example.com"
HTTPS = 'on' HTTPS = 'on'
ROOT_URLCONF = 'lms.urls' ROOT_URLCONF = 'lms.urls'
...@@ -1166,6 +1163,8 @@ MIDDLEWARE_CLASSES = ( ...@@ -1166,6 +1163,8 @@ MIDDLEWARE_CLASSES = (
'course_wiki.middleware.WikiAccessMiddleware', 'course_wiki.middleware.WikiAccessMiddleware',
'openedx.core.djangoapps.theming.middleware.CurrentSiteThemeMiddleware',
# This must be last # This must be last
'microsite_configuration.middleware.MicrositeSessionCookieDomainMiddleware', 'microsite_configuration.middleware.MicrositeSessionCookieDomainMiddleware',
) )
...@@ -1185,7 +1184,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage' ...@@ -1185,7 +1184,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage'
# List of finder classes that know how to find static files in various locations. # List of finder classes that know how to find static files in various locations.
# Note: the pipeline finder is included to be able to discover optimized files # Note: the pipeline finder is included to be able to discover optimized files
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
'openedx.core.djangoapps.theming.finders.ComprehensiveThemeFinder', 'openedx.core.djangoapps.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'openedx.core.lib.xblock_pipeline.finder.XBlockPipelineFinder', 'openedx.core.lib.xblock_pipeline.finder.XBlockPipelineFinder',
...@@ -2920,6 +2919,21 @@ CREDENTIALS_GENERATION_ROUTING_KEY = HIGH_PRIORITY_QUEUE ...@@ -2920,6 +2919,21 @@ CREDENTIALS_GENERATION_ROUTING_KEY = HIGH_PRIORITY_QUEUE
WIKI_REQUEST_CACHE_MIDDLEWARE_CLASS = "request_cache.middleware.RequestCache" WIKI_REQUEST_CACHE_MIDDLEWARE_CLASS = "request_cache.middleware.RequestCache"
# Settings for Comprehensive Theming app
# See https://github.com/edx/edx-django-sites-extensions for more info
# Default site to use if site matching request headers does not exist
SITE_ID = 1
# dir containing all themes
COMPREHENSIVE_THEME_DIRS = [REPO_ROOT / "themes"]
# Theme to use when no site or site theme is defined,
# set to None if you want to use openedx theme
DEFAULT_SITE_THEME = None
ENABLE_COMPREHENSIVE_THEMING = True
# API access management # API access management
API_ACCESS_MANAGER_EMAIL = 'api-access@example.com' API_ACCESS_MANAGER_EMAIL = 'api-access@example.com'
API_ACCESS_FROM_EMAIL = 'api-requests@example.com' API_ACCESS_FROM_EMAIL = 'api-requests@example.com'
......
...@@ -96,7 +96,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.DevelopmentStorage' ...@@ -96,7 +96,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.DevelopmentStorage'
# Revert to the default set of finders as we don't want the production pipeline # Revert to the default set of finders as we don't want the production pipeline
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
'openedx.core.djangoapps.theming.finders.ComprehensiveThemeFinder', 'openedx.core.djangoapps.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
] ]
......
...@@ -41,6 +41,6 @@ STATIC_URL = "/static/" ...@@ -41,6 +41,6 @@ STATIC_URL = "/static/"
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
) )
STATICFILES_DIRS = ( STATICFILES_DIRS = [
(TEST_ROOT / "staticfiles" / "lms").abspath(), (TEST_ROOT / "staticfiles" / "lms").abspath(),
) ]
...@@ -494,6 +494,8 @@ MICROSITE_CONFIGURATION = { ...@@ -494,6 +494,8 @@ MICROSITE_CONFIGURATION = {
MICROSITE_TEST_HOSTNAME = 'testmicrosite.testserver' MICROSITE_TEST_HOSTNAME = 'testmicrosite.testserver'
MICROSITE_LOGISTRATION_HOSTNAME = 'logistration.testserver' MICROSITE_LOGISTRATION_HOSTNAME = 'logistration.testserver'
TEST_THEME = COMMON_ROOT / "test" / "test-theme"
# add extra template directory for test-only templates # add extra template directory for test-only templates
MAKO_TEMPLATES['main'].extend([ MAKO_TEMPLATES['main'].extend([
COMMON_ROOT / 'test' / 'templates', COMMON_ROOT / 'test' / 'templates',
...@@ -582,3 +584,5 @@ JWT_AUTH.update({ ...@@ -582,3 +584,5 @@ JWT_AUTH.update({
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
COURSE_CATALOG_API_URL = 'https://catalog.example.com/api/v1' COURSE_CATALOG_API_URL = 'https://catalog.example.com/api/v1'
COMPREHENSIVE_THEME_DIRS = [REPO_ROOT / "themes", REPO_ROOT / "common/test"]
...@@ -20,7 +20,9 @@ from monkey_patch import ( ...@@ -20,7 +20,9 @@ from monkey_patch import (
import xmodule.x_module import xmodule.x_module
import lms_xblock.runtime import lms_xblock.runtime
from openedx.core.djangoapps.theming.core import enable_comprehensive_theme from openedx.core.djangoapps.theming.core import enable_theming
from openedx.core.djangoapps.theming.helpers import is_comprehensive_theming_enabled
from microsite_configuration import microsite from microsite_configuration import microsite
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -39,8 +41,8 @@ def run(): ...@@ -39,8 +41,8 @@ def run():
# Comprehensive theming needs to be set up before django startup, # Comprehensive theming needs to be set up before django startup,
# because modifying django template paths after startup has no effect. # because modifying django template paths after startup has no effect.
if settings.COMPREHENSIVE_THEME_DIR: if is_comprehensive_theming_enabled():
enable_comprehensive_theme(settings.COMPREHENSIVE_THEME_DIR) enable_theming()
# We currently use 2 template rendering engines, mako and django_templates, # We currently use 2 template rendering engines, mako and django_templates,
# and one of them (django templates), requires the directories be added # and one of them (django templates), requires the directories be added
......
...@@ -6,7 +6,7 @@ $static-path: '../..' !default; ...@@ -6,7 +6,7 @@ $static-path: '../..' !default;
// Configuration // Configuration
@import '../config'; @import '../config';
@import '../base/variables'; @import 'base/variables';
@import '../base-v2/extends'; @import '../base-v2/extends';
// Common extensions // Common extensions
......
<!DOCTYPE html> <!DOCTYPE html>
{% load sekizai_tags i18n microsite pipeline optional_include staticfiles %} {% load sekizai_tags i18n microsite theme_pipeline optional_include staticfiles %}
{% load url from future %} {% load url from future %}
<html lang="{{LANGUAGE_CODE}}"> <html lang="{{LANGUAGE_CODE}}">
<head> <head>
......
{% extends "main_django.html" %} {% extends "main_django.html" %}
{% with online_help_token="wiki" %} {% with online_help_token="wiki" %}
{% load pipeline %}{% load sekizai_tags i18n microsite %}{% load url from future %}{% load staticfiles %} {% load theme_pipeline %}{% load sekizai_tags i18n microsite %}{% load url from future %}{% load staticfiles %}
{% block title %} {% block title %}
{% block pagetitle %}{% endblock %} | {% trans "Wiki" %} | {% platform_name %} {% block pagetitle %}{% endblock %} | {% trans "Wiki" %} | {% platform_name %}
......
<!DOCTYPE html> <!DOCTYPE html>
{% load wiki_tags i18n %}{% load pipeline %} {% load wiki_tags i18n %}{% load theme_pipeline %}
<html lang="{{LANGUAGE_CODE}}"> <html lang="{{LANGUAGE_CODE}}">
<head> <head>
{% stylesheet 'course' %} {% stylesheet 'course' %}
......
...@@ -268,7 +268,7 @@ class BookmarksListViewTests(BookmarksViewsTestsBase): ...@@ -268,7 +268,7 @@ class BookmarksListViewTests(BookmarksViewsTestsBase):
self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.') self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.')
# Send empty data dictionary. # Send empty data dictionary.
with self.assertNumQueries(7): # No queries for bookmark table. with self.assertNumQueries(8): # No queries for bookmark table.
response = self.send_post( response = self.send_post(
client=self.client, client=self.client,
url=reverse('bookmarks'), url=reverse('bookmarks'),
......
"""
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)
""" """
Core logic for Comprehensive Theming. Core logic for Comprehensive Theming.
""" """
from path import Path
from django.conf import settings from django.conf import settings
from .helpers import get_themes
def comprehensive_theme_changes(theme_dir): from logging import getLogger
""" logger = getLogger(__name__) # pylint: disable=invalid-name
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.
def enable_theming():
""" """
Add directories and relevant paths to settings for comprehensive theming.
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_theme(theme_dir):
"""
Add directories to relevant paths for comprehensive theming.
""" """
changes = comprehensive_theme_changes(theme_dir) # Deprecated Warnings
if hasattr(settings, "COMPREHENSIVE_THEME_DIR"):
# Use the changes logger.warning(
for name, value in changes['settings'].iteritems(): "\033[93m \nDeprecated: "
setattr(settings, name, value) "\n\tCOMPREHENSIVE_THEME_DIR setting has been deprecated in favor of COMPREHENSIVE_THEME_DIRS.\033[00m"
for template_dir in changes['template_paths']: )
settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].insert(0, template_dir)
settings.MAKO_TEMPLATES['main'].insert(0, template_dir) for theme in get_themes():
locale_dir = theme.path / "conf" / "locale"
if locale_dir.isdir():
settings.LOCALE_PATHS = (locale_dir, ) + settings.LOCALE_PATHS
if theme.themes_base_dir not in settings.MAKO_TEMPLATES['main']:
settings.MAKO_TEMPLATES['main'].insert(0, theme.themes_base_dir)
...@@ -17,63 +17,80 @@ interface, as well. ...@@ -17,63 +17,80 @@ interface, as well.
.. _Django-Pipeline: http://django-pipeline.readthedocs.org/ .. _Django-Pipeline: http://django-pipeline.readthedocs.org/
.. _Django-Require: https://github.com/etianen/django-require .. _Django-Require: https://github.com/etianen/django-require
""" """
from path import Path import os
from django.conf import settings from collections import OrderedDict
from django.core.exceptions import ImproperlyConfigured
from django.contrib.staticfiles import utils from django.contrib.staticfiles import utils
from django.contrib.staticfiles.finders import BaseFinder from django.contrib.staticfiles.finders import BaseFinder
from openedx.core.djangoapps.theming.storage import CachedComprehensiveThemingStorage from django.utils import six
from openedx.core.djangoapps.theming.helpers import get_themes
from openedx.core.djangoapps.theming.storage import ThemeStorage
class ComprehensiveThemeFinder(BaseFinder): class ThemeFilesFinder(BaseFinder):
""" """
A static files finder that searches the active comprehensive theme A static files finder that looks in the directory of each theme as
for static files. If the ``COMPREHENSIVE_THEME_DIR`` setting is unset, specified in the source_dir attribute.
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): def __init__(self, *args, **kwargs):
super(ComprehensiveThemeFinder, self).__init__(*args, **kwargs) # The list of themes that are handled
self.themes = []
# Mapping of theme names to storage instances
self.storages = OrderedDict()
theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "") themes = get_themes()
if not theme_dir: for theme in themes:
self.storage = None theme_storage = self.storage_class(
return os.path.join(theme.path, self.source_dir),
prefix=theme.theme_dir_name,
)
if not isinstance(theme_dir, basestring): self.storages[theme.theme_dir_name] = theme_storage
raise ImproperlyConfigured("Your COMPREHENSIVE_THEME_DIR setting must be a string") if theme.theme_dir_name not in self.themes:
self.themes.append(theme.theme_dir_name)
root = Path(settings.PROJECT_ROOT) super(ThemeFilesFinder, self).__init__(*args, **kwargs)
if root.name == "":
root = root.parent
component_dir = Path(theme_dir) / root.name def list(self, ignore_patterns):
static_dir = component_dir / "static" """
self.storage = CachedComprehensiveThemingStorage(location=static_dir) 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
def find(self, path, all=False): # pylint: disable=redefined-builtin def find(self, path, all=False): # pylint: disable=redefined-builtin
""" """
Looks for files in the default file storage, if it's local. Looks for files in the theme directories.
""" """
if not self.storage: matches = []
return [] theme_dir_name = path.split("/", 1)[0]
if path.startswith(self.storage.prefix):
# strip the prefix
path = path[len(self.storage.prefix):]
if self.storage.exists(path): themes = {t.theme_dir_name: t for t in get_themes()}
match = self.storage.path(path) # if path is prefixed by theme name then search in the corresponding storage other wise search all storages.
if all: if theme_dir_name in themes:
match = [match] theme = themes[theme_dir_name]
return match path = "/".join(path.split("/")[1:])
match = self.find_in_theme(theme.theme_dir_name, path)
if match:
if not all:
return match
matches.append(match)
return matches
return [] def find_in_theme(self, theme, path):
def list(self, ignore_patterns):
""" """
List all files of the storage. Find a requested static file in an theme's static locations.
""" """
if self.storage and self.storage.exists(''): storage = self.storages.get(theme, None)
for path in utils.get_files(self.storage, ignore_patterns): if storage:
yield path, self.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. Helpers for accessing comprehensive theming related variables.
""" """
from django.conf import settings import re
import os
from path import Path
from django.conf import settings, ImproperlyConfigured
from django.contrib.staticfiles.storage import staticfiles_storage
from request_cache.middleware import RequestCache
from microsite_configuration import microsite, page_title_breadcrumbs from microsite_configuration import microsite, page_title_breadcrumbs
from logging import getLogger
logger = getLogger(__name__) # pylint: disable=invalid-name
def get_page_title_breadcrumbs(*args): def get_page_title_breadcrumbs(*args):
""" """
...@@ -42,7 +52,9 @@ def get_template_path(relative_path, **kwargs): ...@@ -42,7 +52,9 @@ def get_template_path(relative_path, **kwargs):
""" """
This is a proxy function to hide microsite_configuration behind comprehensive theming. This is a proxy function to hide microsite_configuration behind comprehensive theming.
""" """
return microsite.get_template_path(relative_path, **kwargs) if microsite.is_request_in_microsite():
relative_path = microsite.get_template_path(relative_path, **kwargs)
return relative_path
def is_request_in_themed_site(): def is_request_in_themed_site():
...@@ -52,6 +64,14 @@ def is_request_in_themed_site(): ...@@ -52,6 +64,14 @@ def is_request_in_themed_site():
return microsite.is_request_in_microsite() 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): def get_themed_template_path(relative_path, default_path, **kwargs):
""" """
This is a proxy function to hide microsite_configuration behind comprehensive theming. This is a proxy function to hide microsite_configuration behind comprehensive theming.
...@@ -70,3 +90,401 @@ def get_themed_template_path(relative_path, default_path, **kwargs): ...@@ -70,3 +90,401 @@ def get_themed_template_path(relative_path, default_path, **kwargs):
if is_stanford_theming_enabled and not is_microsite: if is_stanford_theming_enabled and not is_microsite:
return relative_path return relative_path
return microsite.get_template_path(default_path, **kwargs) 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.html')
'/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
"""
theme = get_current_theme()
if not theme:
return relative_path
# strip `/` if present at the start of relative_path
template_name = re.sub(r'^/+', '', relative_path)
template_path = theme.template_path / template_name
absolute_path = theme.path / "templates" / template_name
if absolute_path.exists():
return str(template_path)
else:
return relative_path
def get_all_theme_template_dirs():
"""
Returns template directories for all the themes.
Example:
>> get_all_theme_template_dirs()
[
'/edx/app/edxapp/edx-platform/themes/red-theme/lms/templates/',
]
Returns:
(list): list of directories containing theme templates.
"""
themes = get_themes()
template_paths = list()
for theme in themes:
template_paths.extend(theme.template_dirs)
return template_paths
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.
"""
theme = get_current_theme()
if not theme:
return uri
templates_path = "/".join([
theme.theme_dir_name,
get_project_root_name(),
"templates"
])
uri = re.sub(r'^/*' + templates_path + '/*', '', uri)
return uri
def get_current_request():
"""
Return current request instance.
Returns:
(HttpRequest): returns cirrent request
"""
return RequestCache.get_current_request()
def get_current_site():
"""
Return current site.
Returns:
(django.contrib.sites.models.Site): returns current site
"""
request = get_current_request()
if not request:
return None
return getattr(request, 'site', None)
def get_current_site_theme():
"""
Return current site theme object. Returns None if theming is disabled.
Returns:
(ecommerce.theming.models.SiteTheme): site theme object for the current site.
"""
# Return None if theming is disabled
if not is_comprehensive_theming_enabled():
return None
request = get_current_request()
if not request:
return None
return getattr(request, 'site_theme', None)
def get_current_theme():
"""
Return current theme object. Returns None if theming is disabled.
Returns:
(ecommerce.theming.models.SiteTheme): site theme object for the current site.
"""
# Return None if theming is disabled
if not is_comprehensive_theming_enabled():
return None
site_theme = get_current_site_theme()
if not site_theme:
return None
try:
return Theme(
name=site_theme.theme_dir_name,
theme_dir_name=site_theme.theme_dir_name,
themes_base_dir=get_theme_base_dir(site_theme.theme_dir_name),
)
except ValueError as error:
# Log exception message and return None, so that open source theme is used instead
logger.exception('Theme not found in any of the themes dirs. [%s]', error)
return None
def get_theme_base_dir(theme_dir_name, suppress_error=False):
"""
Returns absolute path to the directory that contains the given theme.
Args:
theme_dir_name (str): theme directory name to get base path for
suppress_error (bool): if True function will return None if theme is not found instead of raising an error
Returns:
(str): Base directory that contains the given theme
"""
for themes_dir in get_theme_base_dirs():
if theme_dir_name in get_theme_dirs(themes_dir):
return themes_dir
if suppress_error:
return None
raise ValueError(
"Theme '{theme}' not found in any of the following themes dirs, \nTheme dirs: \n{dir}".format(
theme=theme_dir_name,
dir=get_theme_base_dirs(),
))
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_theme_base_dirs():
"""
Return base directory that contains all the themes.
Raises:
ImproperlyConfigured - exception is raised if
1 - COMPREHENSIVE_THEME_DIRS is not a list
1 - theme dir path is not a string
2 - theme dir path is not an absolute path
3 - path specified in COMPREHENSIVE_THEME_DIRS does not exist
Example:
>> get_theme_base_dirs()
['/edx/app/ecommerce/ecommerce/themes']
Returns:
(Path): Base theme directory path
"""
# Return an empty list if theming is disabled
if not is_comprehensive_theming_enabled():
return []
theme_base_dirs = []
# Legacy code for COMPREHENSIVE_THEME_DIR backward compatibility
if hasattr(settings, "COMPREHENSIVE_THEME_DIR"):
theme_dir = settings.COMPREHENSIVE_THEME_DIR
if not isinstance(theme_dir, basestring):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be a string.")
if not theme_dir.startswith("/"):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be an absolute paths to themes dir.")
if not os.path.isdir(theme_dir):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be a valid path.")
theme_base_dirs.append(Path(theme_dir))
if hasattr(settings, "COMPREHENSIVE_THEME_DIRS"):
theme_dirs = settings.COMPREHENSIVE_THEME_DIRS
if not isinstance(theme_dirs, list):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIRS must be a list.")
if not all([isinstance(theme_dir, basestring) for theme_dir in theme_dirs]):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIRS must contain only strings.")
if not all([theme_dir.startswith("/") for theme_dir in theme_dirs]):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIRS must contain only absolute paths to themes dirs.")
if not all([os.path.isdir(theme_dir) for theme_dir in theme_dirs]):
raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIRS must contain valid paths.")
theme_base_dirs.extend([Path(theme_dir) for theme_dir in theme_dirs])
return theme_base_dirs
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
"""
# Disable theming for microsites
if microsite.is_request_in_microsite():
return False
return settings.ENABLE_COMPREHENSIVE_THEMING
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-v1.css')
'/static/red-theme/css/lms-main-v1.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(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_dirs = [Path(themes_dir)] if themes_dir else get_theme_base_dirs()
# pick only directories and discard files in themes directory
themes = []
for themes_dir in themes_dirs:
themes.extend([Theme(name, name, themes_dir) for name in get_theme_dirs(themes_dir)])
return themes
def get_theme_dirs(themes_dir=None):
"""
Returns theme dirs in given dirs
Args:
themes_dir (Path): base dir that contains themes.
"""
return [_dir for _dir in os.listdir(themes_dir) if is_theme_dir(themes_dir / _dir)]
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_name = ''
themes_base_dir = None
def __init__(self, name='', theme_dir_name='', themes_base_dir=None):
"""
init method for Theme
Args:
name: name if the theme
theme_dir_name: directory name of the theme
themes_base_dir: directory path of the folder that contains the theme
"""
self.name = name
self.theme_dir_name = theme_dir_name
self.themes_base_dir = themes_base_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_name, self.path) == (other.theme_dir_name, other.path)
def __hash__(self):
return hash((self.theme_dir_name, 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):
"""
Get absolute path of the directory that contains current theme's templates, static assets etc.
Returns:
Path: absolute path to current theme's contents
"""
return Path(self.themes_base_dir) / self.theme_dir_name / get_project_root_name()
@property
def template_path(self):
"""
Get absolute path of current theme's template directory.
Returns:
Path: absolute path to current theme's template directory
"""
return Path(self.theme_dir_name) / get_project_root_name() / 'templates'
@property
def template_dirs(self):
"""
Get a list of all template directories for current theme.
Returns:
list: list of all template directories for current theme.
"""
return [
self.path / 'templates',
]
"""
Management commands related to Comprehensive Theming.
"""
"""
Management command for compiling sass.
"""
from __future__ import unicode_literals
from django.core.management import BaseCommand, CommandError
from paver.easy import call_task
from pavelib.assets import ALL_SYSTEMS
from openedx.core.djangoapps.theming.helpers import get_themes, get_theme_base_dirs, is_comprehensive_theming_enabled
class Command(BaseCommand):
"""
Compile theme sass and collect theme assets.
"""
help = 'Compile and collect themed assets...'
def add_arguments(self, parser):
"""
Add arguments for compile_sass command.
Args:
parser (django.core.management.base.CommandParser): parsed for parsing command line arguments.
"""
parser.add_argument(
'system', type=str, nargs='*', default=ALL_SYSTEMS,
help="lms or studio",
)
# Named (optional) arguments
parser.add_argument(
'--theme-dirs',
dest='theme_dirs',
type=str,
nargs='+',
default=None,
help="List of dirs where given themes would be looked.",
)
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.",
)
# Named (optional) arguments
parser.add_argument(
'--force',
action='store_true',
default=False,
help="Force full compilation",
)
parser.add_argument(
'--debug',
action='store_true',
default=False,
help="Disable Sass compression",
)
@staticmethod
def parse_arguments(*args, **options): # pylint: disable=unused-argument
"""
Parse and validate arguments for compile_sass 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. system (list): list of system names for whom to compile theme sass e.g. 'lms', 'cms'
2. theme_dirs (list): list of Theme objects
3. themes (list): list of Theme objects
4. force (bool): Force full compilation
5. debug (bool): Disable Sass compression
"""
system = options.get("system", ALL_SYSTEMS)
given_themes = options.get("themes", ["all"])
theme_dirs = options.get("theme_dirs", None)
force = options.get("force", True)
debug = options.get("debug", True)
if theme_dirs:
available_themes = {}
for theme_dir in theme_dirs:
available_themes.update({t.theme_dir_name: t for t in get_themes(theme_dir)})
else:
theme_dirs = get_theme_base_dirs()
available_themes = {t.theme_dir_name: 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 '{themes}' do not exist inside any of the theme directories '{theme_dirs}'".format(
themes=", ".join(set(given_themes) - set(available_themes.keys())),
theme_dirs=theme_dirs,
),
)
if "all" in given_themes:
themes = list(available_themes.itervalues())
elif "no" in given_themes:
themes = []
else:
# convert theme names to Theme objects, this will remove all themes if theming is disabled
themes = [available_themes.get(theme) for theme in given_themes if theme in available_themes]
return system, theme_dirs, themes, force, debug
def handle(self, *args, **options):
"""
Handle compile_sass command.
"""
system, theme_dirs, themes, force, debug = self.parse_arguments(*args, **options)
themes = [theme.theme_dir_name for theme in themes]
if options.get("themes", None) and not is_comprehensive_theming_enabled():
# log a warning message to let the user know that asset compilation for themes is skipped
self.stdout.write(
self.style.WARNING( # pylint: disable=no-member
"Skipping theme asset compilation: enable theming to process themed assets"
),
)
call_task(
'pavelib.assets.compile_sass',
options={'system': system, 'theme-dirs': theme_dirs, 'themes': themes, 'force': force, 'debug': debug},
)
"""
Middleware for theming app
Note:
This middleware depends on "django_sites_extensions" app
So it must be added to INSTALLED_APPS in django settings files.
"""
from openedx.core.djangoapps.theming.models import SiteTheme
class CurrentSiteThemeMiddleware(object):
"""
Middleware that sets `site_theme` attribute to request object.
"""
def process_request(self, request):
"""
fetch Site Theme for the current site and add it to the request object.
"""
request.site_theme = SiteTheme.get_theme(request.site)
# -*- 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')),
],
),
]
"""
Django models supporting the Comprehensive Theming subsystem
"""
from django.db import models
from django.conf import settings
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
@staticmethod
def get_theme(site):
"""
Get SiteTheme object for given site, returns default site theme if it can not
find a theme for the given site and `DEFAULT_SITE_THEME` setting has a proper value.
Args:
site (django.contrib.sites.models.Site): site object related to the current site.
Returns:
SiteTheme object for given site or a default site set by `DEFAULT_SITE_THEME`
"""
theme = site.themes.first()
if (not theme) and settings.DEFAULT_SITE_THEME:
theme = SiteTheme(site=site, theme_dir_name=settings.DEFAULT_SITE_THEME)
return theme
"""
This file contains helpers for paver commands, Django is not initialized in paver commands.
So, django settings, models etc. can not be used here.
"""
import os
from path import Path
def get_theme_paths(themes, theme_dirs):
"""
get absolute path for all the given themes, if a theme is no found
at multiple places than all paths for the theme will be included.
If a theme is not found anywhere then theme will be skipped with
an error message printed on the console.
If themes is 'None' then all themes in given dirs are returned.
Args:
themes (list): list of all theme names
theme_dirs (list): list of base dirs that contain themes
Returns:
list of absolute paths to themes.
"""
theme_paths = []
for theme in themes:
theme_base_dirs = get_theme_base_dirs(theme, theme_dirs)
if not theme_base_dirs:
print(
"\033[91m\nSkipping '{theme}': \n"
"Theme ({theme}) not found in any of the theme dirs ({theme_dirs}). \033[00m".format(
theme=theme,
theme_dirs=", ".join(theme_dirs)
),
)
theme_paths.extend(theme_base_dirs)
return theme_paths
def get_theme_base_dirs(theme, theme_dirs):
"""
Get all base dirs where the given theme can be found.
Args:
theme (str): name of the theme to find
theme_dirs (list): list of all base dirs where the given theme could be found
Returns:
list of all the dirs for the goven theme
"""
theme_paths = []
for _dir in theme_dirs:
for dir_name in {theme}.intersection(os.listdir(_dir)):
if is_theme_dir(Path(_dir) / dir_name):
theme_paths.append(Path(_dir) / dir_name)
return theme_paths
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)))
...@@ -2,87 +2,304 @@ ...@@ -2,87 +2,304 @@
Comprehensive Theming support for Django's collectstatic functionality. Comprehensive Theming support for Django's collectstatic functionality.
See https://docs.djangoproject.com/en/1.8/ref/contrib/staticfiles/ See https://docs.djangoproject.com/en/1.8/ref/contrib/staticfiles/
""" """
from path import Path import posixpath
import os.path import os.path
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.contrib.staticfiles.storage import StaticFilesStorage, CachedFilesMixin
from django.utils._os import safe_join from django.utils._os import safe_join
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_theme_base_dir,
get_project_root_name,
get_current_theme,
get_themes,
is_comprehensive_theming_enabled,
)
class ComprehensiveThemingAwareMixin(object):
class ThemeStorage(StaticFilesStorage):
""" """
Mixin for Django storage system to make it aware of the currently-active Comprehensive theme aware Static files storage.
comprehensive theme, so that it can generate theme-scoped URLs for themed
static assets.
""" """
def __init__(self, *args, **kwargs): # prefix for file path, this prefix is added at the beginning of file path before saving static files during
super(ComprehensiveThemingAwareMixin, self).__init__(*args, **kwargs) # collectstatic command.
theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "") # e.g. having "edx.org" as prefix will cause files to be saved as "edx.org/images/logo.png"
if not theme_dir: # instead of "images/logo.png"
self.theme_location = None prefix = None
return
if not isinstance(theme_dir, basestring):
raise ImproperlyConfigured("Your COMPREHENSIVE_THEME_DIR setting must be a string")
root = Path(settings.PROJECT_ROOT) def __init__(self, location=None, base_url=None, file_permissions_mode=None,
if root.name == "": directory_permissions_mode=None, prefix=None):
root = root.parent
component_dir = Path(theme_dir) / root.name self.prefix = prefix
self.theme_location = component_dir / "static" super(ThemeStorage, self).__init__(
location=location,
base_url=base_url,
file_permissions_mode=file_permissions_mode,
directory_permissions_mode=directory_permissions_mode,
)
@property def url(self, name):
def prefix(self):
""" """
This is used by the ComprehensiveThemeFinder in the collection step. 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'
""" """
theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "") prefix = ''
if not theme_dir: theme = get_current_theme()
return None
theme_name = os.path.basename(os.path.normpath(theme_dir)) # get theme prefix from site address if if asset is accessed via a url
return "themes/{name}/".format(name=theme_name) if theme:
prefix = theme.theme_dir_name
# 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)
def themed(self, name): return super(ThemeStorage, self).url(name)
def themed(self, name, theme):
""" """
Given a name, return a boolean indicating whether that name exists Returns True if given asset override is provided by the given theme otherwise returns False.
as a themed asset in the comprehensive theme. 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
""" """
# Nothing can be themed if we don't have a theme location. if not is_comprehensive_theming_enabled():
if not self.theme_location:
return False return False
path = safe_join(self.theme_location, name) # in debug mode check static asset from within the project directory
return os.path.exists(path) if settings.DEBUG:
themes_location = get_theme_base_dir(theme, suppress_error=True)
# 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 ThemeCachedFilesMixin(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_theme` 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.
def path(self, name): 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):
""" """
Get the path to the real asset on disk Returns themed url for the given asset.
""" """
if self.themed(name): theme = get_current_theme()
base = self.theme_location if theme and theme.theme_dir_name not in name:
else: # during server run, append theme name to the asset name if it is not already there
base = self.location # this is ensure that correct hash is created and default asset is not always
path = safe_join(base, name) # used to create hash of themed assets.
return os.path.normpath(path) name = os.path.join(theme.theme_dir_name, 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_name for theme in get_themes()]:
asset_name = "/".join(name.split("/")[1:])
def url(self, name, *args, **kwargs): return super(ThemeCachedFilesMixin, self).url(asset_name, force)
def url_converter(self, name, template=None):
""" """
Add the theme prefix to the asset URL 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 self.themed(name): if template is None:
name = self.prefix + name template = self.default_template
return super(ComprehensiveThemingAwareMixin, self).url(name, *args, **kwargs)
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)
class CachedComprehensiveThemingStorage( # Return the hashed version to the file
ComprehensiveThemingAwareMixin, return template % unquote(hashed_url)
CachedFilesMixin,
StaticFilesStorage return converter
):
class ThemePipelineMixin(PipelineMixin):
""" """
Used by the ComprehensiveThemeFinder class. Mixes in support for cached Mixin to make sure themed assets are also packaged and used along with non themed assets.
files and comprehensive theming in static files. 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'
""" """
pass 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:
return
themes = get_themes()
for theme in themes:
css_packages = self.get_themed_packages(theme.theme_dir_name, settings.PIPELINE_CSS)
js_packages = self.get_themed_packages(theme.theme_dir_name, settings.PIPELINE_JS)
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
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
@staticmethod
def get_themed_packages(prefix, packages):
"""
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
"""
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)
themed_packages[name] = {
'output_filename': os.path.join(prefix, packages[name].get('output_filename', '')),
'source_filenames': source_files,
}
return themed_packages
"""
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 edxmako.makoloader import MakoLoader
from openedx.core.djangoapps.theming.helpers import get_current_request, \
get_current_theme, get_all_theme_template_dirs
class ThemeTemplateLoader(MakoLoader):
"""
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):
MakoLoader.__init__(self, ThemeFilesystemLoader(*args))
class ThemeFilesystemLoader(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
theme = get_current_theme()
return theme and theme.template_dirs
"""
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)
"""
Tests for Management commands of comprehensive theming.
"""
from django.test import TestCase
from django.core.management import call_command, CommandError
from openedx.core.djangoapps.theming.helpers import get_themes
from openedx.core.djangoapps.theming.management.commands.compile_sass import Command
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("compile_sass", themes=["all", "test-theme"])
# make sure error is raised for invalid theme list
with self.assertRaises(CommandError):
call_command("compile_sass", themes=["no", "test-theme"])
# make sure error is raised for invalid theme list
with self.assertRaises(CommandError):
call_command("compile_sass", themes=["all", "no"])
# make sure error is raised for invalid theme list
with self.assertRaises(CommandError):
call_command("compile_sass", themes=["test-theme", "non-existing-theme"])
def test_parse_arguments(self):
"""
Test parse arguments method for update_asset command.
"""
# make sure compile_sass picks all themes when called with 'themes=all' option
parsed_args = Command.parse_arguments(themes=["all"])
self.assertItemsEqual(parsed_args[2], get_themes())
# make sure compile_sass picks no themes when called with 'themes=no' option
parsed_args = Command.parse_arguments(themes=["no"])
self.assertItemsEqual(parsed_args[2], [])
# make sure compile_sass picks only specified themes
parsed_args = Command.parse_arguments(themes=["test-theme"])
self.assertItemsEqual(parsed_args[2], [theme for theme in get_themes() if theme.theme_dir_name == "test-theme"])
"""
Tests for comprehensive theme static files finders.
"""
import unittest
from django.conf import settings
from django.test import TestCase
from openedx.core.djangoapps.theming.finders import ThemeFilesFinder
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
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 = settings.COMPREHENSIVE_THEME_DIRS[1]
asset = "test-theme/images/logo.png"
match = self.finder.find(asset)
self.assertEqual(match, themes_dir / "test-theme" / "lms" / "static" / "images" / "logo.png")
def test_find_all_themed_asset(self):
"""
Verify Theme Finder returns themed assets
"""
themes_dir = settings.COMPREHENSIVE_THEME_DIRS[1]
asset = "test-theme/images/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" / "lms" / "static" / "images" / "logo.png")
def test_find_in_theme(self):
"""
Verify find in theme method of finders returns asset from specified theme
"""
themes_dir = settings.COMPREHENSIVE_THEME_DIRS[1]
asset = "images/logo.png"
match = self.finder.find_in_theme("test-theme", asset)
self.assertEqual(match, themes_dir / "test-theme" / "lms" / "static" / "images" / "logo.png")
""" """
Test helpers for Comprehensive Theming. Test helpers for Comprehensive Theming.
""" """
from django.test import TestCase import unittest
from mock import patch from mock import patch
from django.test import TestCase, override_settings
from django.conf import settings
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
from openedx.core.djangoapps.theming import helpers from openedx.core.djangoapps.theming import helpers
from openedx.core.djangoapps.theming.helpers import get_template_path_with_theme, strip_site_theme_templates_path, \
get_themes, Theme, get_theme_base_dir
class TestHelpers(TestCase):
"""Test comprehensive theming helper functions."""
class ThemingHelpersTests(TestCase): def test_get_themes(self):
""" """
Make sure some of the theming helper functions work Tests template paths are returned from enabled theme.
""" """
expected_themes = [
Theme('test-theme', 'test-theme', get_theme_base_dir('test-theme')),
Theme('red-theme', 'red-theme', get_theme_base_dir('red-theme')),
Theme('edge.edx.org', 'edge.edx.org', get_theme_base_dir('edge.edx.org')),
Theme('edx.org', 'edx.org', get_theme_base_dir('edx.org')),
Theme('stanford-style', 'stanford-style', get_theme_base_dir('stanford-style')),
]
actual_themes = get_themes()
self.assertItemsEqual(expected_themes, actual_themes)
@override_settings(COMPREHENSIVE_THEME_DIRS=[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', get_theme_base_dir('test-theme')),
]
actual_themes = get_themes()
self.assertItemsEqual(expected_themes, actual_themes)
def test_get_value_returns_override(self): def test_get_value_returns_override(self):
""" """
...@@ -23,3 +52,89 @@ class ThemingHelpersTests(TestCase): ...@@ -23,3 +52,89 @@ class ThemingHelpersTests(TestCase):
mock_get_value.return_value = {override_key: override_value} mock_get_value.return_value = {override_key: override_value}
jwt_auth = helpers.get_value('JWT_AUTH') jwt_auth = helpers.get_value('JWT_AUTH')
self.assertEqual(jwt_auth[override_key], override_value) self.assertEqual(jwt_auth[override_key], override_value)
@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')
@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')
"""
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_theme_base_dirs, Theme, get_theme_base_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_theme_base_dirs()[0]
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_theme",
return_value=Theme(self.enabled_theme, self.enabled_theme, get_theme_base_dir(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_theme",
return_value=Theme(self.enabled_theme, self.enabled_theme, get_theme_base_dir(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)
"""
Tests for comprehensive themes.
"""
import unittest
from django.conf import settings
from django.test import TestCase, override_settings
from django.contrib import staticfiles
from openedx.core.djangoapps.theming.tests.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()
@override_settings(COMPREHENSIVE_THEME_DIRS=[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_DIRS=[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')
@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()
@override_settings(COMPREHENSIVE_THEME_DIRS=[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.")
@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()
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')
@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()
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.")
...@@ -6,87 +6,63 @@ from functools import wraps ...@@ -6,87 +6,63 @@ from functools import wraps
import os import os
import os.path import os.path
import contextlib import contextlib
import re
from mock import patch from mock import patch
from django.conf import settings from django.conf import settings
from django.template import Engine from django.contrib.sites.models import Site
from django.test.utils import override_settings
import edxmako import edxmako
from openedx.core.djangoapps.theming.models import SiteTheme
from openedx.core.djangoapps.theming.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 particular comprehensive theme. A decorator to run a test with a comprehensive theming enabled.
Arguments: Arguments:
theme_dir (str): the full path to the theme directory to use. theme_dir_name (str): directory name of the site for which we want comprehensive theming enabled.
This will likely use `settings.REPO_ROOT` to get the full path.
""" """
# This decorator gets the settings changes needed for a theme, and applies # This decorator creates Site and SiteTheme models for given domain
# 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 def _decorator(func): # pylint: disable=missing-docstring
@wraps(func) @wraps(func)
def _decorated(*args, **kwargs): # pylint: disable=missing-docstring def _decorated(*args, **kwargs): # pylint: disable=missing-docstring
with override_settings(COMPREHENSIVE_THEME_DIR=theme_dir, **changes['settings']): # make a domain name out of directory name
default_engine = Engine.get_default() domain = "{theme_dir_name}.org".format(theme_dir_name=re.sub(r"\.org$", "", theme_dir_name))
dirs = default_engine.dirs[:] site, __ = Site.objects.get_or_create(domain=domain, name=domain)
with edxmako.save_lookups(): site_theme, __ = SiteTheme.objects.get_or_create(site=site, theme_dir_name=theme_dir_name)
for template_dir in changes['template_paths']:
edxmako.paths.add_lookup('main', template_dir, prepend=True) for _dir in settings.COMPREHENSIVE_THEME_DIRS:
dirs.insert(0, template_dir) edxmako.paths.add_lookup('main', _dir, prepend=True)
with patch.object(default_engine, 'dirs', dirs):
return func(*args, **kwargs) with patch('openedx.core.djangoapps.theming.helpers.get_current_site_theme',
return_value=site_theme):
with patch('openedx.core.djangoapps.theming.helpers.get_current_site', return_value=site):
return func(*args, **kwargs)
return _decorated return _decorated
return _decorator return _decorator
def with_is_edx_domain(is_edx_domain):
"""
A decorator 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?
"""
# 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 @contextlib.contextmanager
def with_edx_domain_context(is_edx_domain): def with_comprehensive_theme_context(theme=None):
""" """
A function to run a test as if request originated from edX domain or not. A function to run a test as if request was made to the given theme.
Arguments: Arguments:
is_edx_domain (bool): are we an edX domain or not? theme (str): name if the theme or None if no theme is applied
""" """
if is_edx_domain: if theme:
changes = comprehensive_theme_changes(EDX_THEME_DIR) domain = '{theme}.org'.format(theme=re.sub(r"\.org$", "", theme))
with override_settings(COMPREHENSIVE_THEME_DIR=EDX_THEME_DIR, **changes['settings']): site, __ = Site.objects.get_or_create(domain=domain, name=theme)
with edxmako.save_lookups(): site_theme, __ = SiteTheme.objects.get_or_create(site=site, theme_dir_name=theme)
for template_dir in changes['template_paths']:
edxmako.paths.add_lookup('main', template_dir, prepend=True) for _dir in settings.COMPREHENSIVE_THEME_DIRS:
edxmako.paths.add_lookup('main', _dir, prepend=True)
with patch('openedx.core.djangoapps.theming.helpers.get_current_site_theme',
return_value=site_theme):
with patch('openedx.core.djangoapps.theming.helpers.get_current_site', return_value=site):
yield yield
else: else:
yield yield
......
...@@ -248,7 +248,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -248,7 +248,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase):
""" """
self.different_client.login(username=self.different_user.username, password=self.test_password) self.different_client.login(username=self.different_user.username, password=self.test_password)
self.create_mock_profile(self.user) self.create_mock_profile(self.user)
with self.assertNumQueries(17): with self.assertNumQueries(18):
response = self.send_get(self.different_client) response = self.send_get(self.different_client)
self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY) self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY)
...@@ -263,7 +263,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -263,7 +263,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase):
""" """
self.different_client.login(username=self.different_user.username, password=self.test_password) self.different_client.login(username=self.different_user.username, password=self.test_password)
self.create_mock_profile(self.user) self.create_mock_profile(self.user)
with self.assertNumQueries(17): with self.assertNumQueries(18):
response = self.send_get(self.different_client) response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY) self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
...@@ -337,12 +337,12 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -337,12 +337,12 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase):
self.assertEqual(False, data["accomplishments_shared"]) self.assertEqual(False, data["accomplishments_shared"])
self.client.login(username=self.user.username, password=self.test_password) self.client.login(username=self.user.username, password=self.test_password)
verify_get_own_information(15) verify_get_own_information(16)
# Now make sure that the user can get the same information, even if not active # Now make sure that the user can get the same information, even if not active
self.user.is_active = False self.user.is_active = False
self.user.save() self.user.save()
verify_get_own_information(10) verify_get_own_information(11)
def test_get_account_empty_string(self): def test_get_account_empty_string(self):
""" """
...@@ -356,7 +356,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -356,7 +356,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase):
legacy_profile.save() legacy_profile.save()
self.client.login(username=self.user.username, password=self.test_password) self.client.login(username=self.user.username, password=self.test_password)
with self.assertNumQueries(15): with self.assertNumQueries(16):
response = self.send_get(self.client) response = self.send_get(self.client)
for empty_field in ("level_of_education", "gender", "country", "bio"): for empty_field in ("level_of_education", "gender", "country", "bio"):
self.assertIsNone(response.data[empty_field]) self.assertIsNone(response.data[empty_field])
......
...@@ -17,3 +17,22 @@ def cleanup_tempdir(the_dir): ...@@ -17,3 +17,22 @@ def cleanup_tempdir(the_dir):
"""Called on process exit to remove a temp directory.""" """Called on process exit to remove a temp directory."""
if os.path.exists(the_dir): if os.path.exists(the_dir):
shutil.rmtree(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)
...@@ -2,18 +2,22 @@ ...@@ -2,18 +2,22 @@
Django storage backends for Open edX. Django storage backends for Open edX.
""" """
from django_pipeline_forgiving.storages import PipelineForgivingStorage from django_pipeline_forgiving.storages import PipelineForgivingStorage
from django.contrib.staticfiles.storage import StaticFilesStorage, CachedFilesMixin from django.contrib.staticfiles.storage import StaticFilesStorage
from pipeline.storage import PipelineMixin, NonPackagingMixin from pipeline.storage import NonPackagingMixin
from require.storage import OptimizedFilesMixin from require.storage import OptimizedFilesMixin
from openedx.core.djangoapps.theming.storage import ComprehensiveThemingAwareMixin from openedx.core.djangoapps.theming.storage import (
ThemeStorage,
ThemeCachedFilesMixin,
ThemePipelineMixin
)
class ProductionStorage( class ProductionStorage(
PipelineForgivingStorage, PipelineForgivingStorage,
ComprehensiveThemingAwareMixin,
OptimizedFilesMixin, OptimizedFilesMixin,
PipelineMixin, ThemePipelineMixin,
CachedFilesMixin, ThemeCachedFilesMixin,
ThemeStorage,
StaticFilesStorage StaticFilesStorage
): ):
""" """
...@@ -24,9 +28,9 @@ class ProductionStorage( ...@@ -24,9 +28,9 @@ class ProductionStorage(
class DevelopmentStorage( class DevelopmentStorage(
ComprehensiveThemingAwareMixin,
NonPackagingMixin, NonPackagingMixin,
PipelineMixin, ThemePipelineMixin,
ThemeStorage,
StaticFilesStorage StaticFilesStorage
): ):
""" """
......
...@@ -18,28 +18,28 @@ from watchdog.events import PatternMatchingEventHandler ...@@ -18,28 +18,28 @@ from watchdog.events import PatternMatchingEventHandler
from .utils.envs import Env from .utils.envs import Env
from .utils.cmd import cmd, django_cmd from .utils.cmd import cmd, django_cmd
from openedx.core.djangoapps.theming.paver_helpers import get_theme_paths
# setup baseline paths # setup baseline paths
ALL_SYSTEMS = ['lms', 'studio'] ALL_SYSTEMS = ['lms', 'studio']
COFFEE_DIRS = ['lms', 'cms', 'common'] COFFEE_DIRS = ['lms', 'cms', 'common']
# A list of directories. Each will be paired with a sibling /css directory.
COMMON_SASS_DIRECTORIES = [ LMS = 'lms'
CMS = 'cms'
SYSTEMS = {
'lms': LMS,
'cms': CMS,
'studio': CMS
}
# Common lookup paths that are added to the lookup paths for all sass compilations
COMMON_LOOKUP_PATHS = [
path("common/static"),
path("common/static/sass"), path("common/static/sass"),
] path('node_modules'),
LMS_SASS_DIRECTORIES = [ path('node_modules/edx-pattern-library/node_modules'),
path("lms/static/sass"),
path("lms/static/themed_sass"),
path("lms/static/certificates/sass"),
]
CMS_SASS_DIRECTORIES = [
path("cms/static/sass"),
]
THEME_SASS_DIRECTORIES = []
SASS_LOAD_PATHS = [
'common/static',
'common/static/sass',
'node_modules',
'node_modules/edx-pattern-library/node_modules',
] ]
# A list of NPM installed libraries that should be copied into the common # A list of NPM installed libraries that should be copied into the common
...@@ -58,60 +58,197 @@ NPM_INSTALLED_LIBRARIES = [ ...@@ -58,60 +58,197 @@ NPM_INSTALLED_LIBRARIES = [
# Directory to install static vendor files # Directory to install static vendor files
NPM_VENDOR_DIRECTORY = path("common/static/common/js/vendor") NPM_VENDOR_DIRECTORY = path("common/static/common/js/vendor")
# system specific lookup path additions, add sass dirs if one system depends on the sass files for other systems
SASS_LOOKUP_DEPENDENCIES = {
'cms': [path('lms') / 'static' / 'sass' / 'partials', ],
}
def configure_paths():
"""Configure our paths based on settings. Called immediately."""
edxapp_env = Env()
if edxapp_env.feature_flags.get('USE_CUSTOM_THEME', False):
theme_name = edxapp_env.env_tokens.get('THEME_NAME', '')
parent_dir = path(edxapp_env.REPO_ROOT).abspath().parent
theme_root = parent_dir / "themes" / theme_name
COFFEE_DIRS.append(theme_root)
sass_dir = theme_root / "static" / "sass"
css_dir = theme_root / "static" / "css"
if sass_dir.isdir():
css_dir.mkdir_p()
THEME_SASS_DIRECTORIES.append(sass_dir)
if edxapp_env.env_tokens.get("COMPREHENSIVE_THEME_DIR", ""):
theme_dir = path(edxapp_env.env_tokens["COMPREHENSIVE_THEME_DIR"])
lms_sass = theme_dir / "lms" / "static" / "sass"
lms_css = theme_dir / "lms" / "static" / "css"
if lms_sass.isdir():
lms_css.mkdir_p()
THEME_SASS_DIRECTORIES.append(lms_sass)
cms_sass = theme_dir / "cms" / "static" / "sass"
cms_css = theme_dir / "cms" / "static" / "css"
if cms_sass.isdir():
cms_css.mkdir_p()
THEME_SASS_DIRECTORIES.append(cms_sass)
configure_paths()
def applicable_sass_directories(systems=None):
"""
Determine the applicable set of SASS directories to be
compiled for the specified list of systems.
Args: def get_sass_directories(system, theme_dir=None):
systems: A list of systems (defaults to all) """
Determine the set of SASS directories to be compiled for the specified list of system and theme
and return a list of those directories.
Each item in the list is dict object containing the following key-value pairs.
{
"sass_source_dir": "", # directory where source sass files are present
"css_destination_dir": "", # destination where css files would be placed
"lookup_paths": [], # list of directories to be passed as lookup paths for @import resolution.
}
if theme_dir is empty or None then return sass directories for the given system only. (i.e. lms or cms)
:param system: name if the system for which to compile sass e.g. 'lms', 'cms'
:param theme_dir: absolute path of theme for which to compile sass files.
"""
if system not in SYSTEMS:
raise ValueError("'system' must be one of ({allowed_values})".format(allowed_values=', '.join(SYSTEMS.keys())))
system = SYSTEMS[system]
applicable_directories = list()
if theme_dir:
# Add theme sass directories
applicable_directories.extend(
get_theme_sass_dirs(system, theme_dir)
)
else:
# add system sass directories
applicable_directories.extend(
get_system_sass_dirs(system)
)
return applicable_directories
def get_common_sass_directories():
"""
Determine the set of common SASS directories to be compiled for all the systems and themes.
Each item in the returned list is dict object containing the following key-value pairs.
{
"sass_source_dir": "", # directory where source sass files are present
"css_destination_dir": "", # destination where css files would be placed
"lookup_paths": [], # list of directories to be passed as lookup paths for @import resolution.
}
"""
applicable_directories = list()
# add common sass directories
applicable_directories.append({
"sass_source_dir": path("common/static/sass"),
"css_destination_dir": path("common/static/css"),
"lookup_paths": COMMON_LOOKUP_PATHS,
})
Returns:
A list of SASS directories to be compiled.
"""
if not systems:
systems = ALL_SYSTEMS
applicable_directories = []
applicable_directories.extend(COMMON_SASS_DIRECTORIES)
if "lms" in systems:
applicable_directories.extend(LMS_SASS_DIRECTORIES)
if "studio" in systems or "cms" in systems:
applicable_directories.extend(CMS_SASS_DIRECTORIES)
applicable_directories.extend(THEME_SASS_DIRECTORIES)
return applicable_directories return applicable_directories
def get_theme_sass_dirs(system, theme_dir):
"""
Return list of sass dirs that need to be compiled for the given theme.
:param system: name if the system for which to compile sass e.g. 'lms', 'cms'
:param theme_dir: absolute path of theme for which to compile sass files.
"""
if system not in ('lms', 'cms'):
raise ValueError('"system" must either be "lms" or "cms"')
dirs = []
system_sass_dir = path(system) / "static" / "sass"
sass_dir = theme_dir / system / "static" / "sass"
css_dir = theme_dir / system / "static" / "css"
dependencies = SASS_LOOKUP_DEPENDENCIES.get(system, [])
if sass_dir.isdir():
css_dir.mkdir_p()
# first compile lms sass files and place css in theme dir
dirs.append({
"sass_source_dir": system_sass_dir,
"css_destination_dir": css_dir,
"lookup_paths": dependencies + [
sass_dir / "partials",
system_sass_dir / "partials",
system_sass_dir,
],
})
# now compile theme sass files and override css files generated from lms
dirs.append({
"sass_source_dir": sass_dir,
"css_destination_dir": css_dir,
"lookup_paths": dependencies + [
sass_dir / "partials",
system_sass_dir / "partials",
system_sass_dir,
],
})
return dirs
def get_system_sass_dirs(system):
"""
Return list of sass dirs that need to be compiled for the given system.
:param system: name if the system for which to compile sass e.g. 'lms', 'cms'
"""
if system not in ('lms', 'cms'):
raise ValueError('"system" must either be "lms" or "cms"')
dirs = []
sass_dir = path(system) / "static" / "sass"
css_dir = path(system) / "static" / "css"
dependencies = SASS_LOOKUP_DEPENDENCIES.get(system, [])
dirs.append({
"sass_source_dir": sass_dir,
"css_destination_dir": css_dir,
"lookup_paths": dependencies + [
sass_dir / "partials",
sass_dir,
],
})
if system == 'lms':
dirs.append({
"sass_source_dir": path(system) / "static" / "certificates" / "sass",
"css_destination_dir": path(system) / "static" / "certificates" / "css",
"lookup_paths": [
sass_dir / "partials",
sass_dir
],
})
return dirs
def get_watcher_dirs(theme_dirs=None, themes=None):
"""
Return sass directories that need to be added to sass watcher.
Example:
>> get_watcher_dirs('/edx/app/edx-platform/themes', ['red-theme'])
[
'common/static',
'common/static/sass',
'lms/static/sass',
'lms/static/sass/partials',
'/edx/app/edxapp/edx-platform/themes/red-theme/lms/static/sass',
'/edx/app/edxapp/edx-platform/themes/red-theme/lms/static/sass/partials',
'cms/static/sass',
'cms/static/sass/partials',
'/edx/app/edxapp/edx-platform/themes/red-theme/cms/static/sass/partials',
]
Parameters:
theme_dirs (list): list of theme base directories.
themes (list): list containing names of themes
Returns:
(list): dirs that need to be added to sass watchers.
"""
dirs = []
dirs.extend(COMMON_LOOKUP_PATHS)
if theme_dirs and themes:
# Register sass watchers for all the given themes
themes = get_theme_paths(themes=themes, theme_dirs=theme_dirs)
for theme in themes:
for _dir in get_sass_directories('lms', theme) + get_sass_directories('cms', theme):
dirs.append(_dir['sass_source_dir'])
dirs.extend(_dir['lookup_paths'])
# Register sass watchers for lms and cms
for _dir in get_sass_directories('lms') + get_sass_directories('cms') + get_common_sass_directories():
dirs.append(_dir['sass_source_dir'])
dirs.extend(_dir['lookup_paths'])
# remove duplicates
dirs = list(set(dirs))
return dirs
def debounce(seconds=1): def debounce(seconds=1):
""" """
Prevents the decorated function from being called more than every `seconds` Prevents the decorated function from being called more than every `seconds`
...@@ -169,11 +306,15 @@ class SassWatcher(PatternMatchingEventHandler): ...@@ -169,11 +306,15 @@ class SassWatcher(PatternMatchingEventHandler):
patterns = ['*.scss'] patterns = ['*.scss']
ignore_patterns = ['common/static/xmodule/*'] ignore_patterns = ['common/static/xmodule/*']
def register(self, observer): def register(self, observer, directories):
""" """
register files with observer register files with observer
Arguments:
observer (watchdog.observers.Observer): sass file observer
directories (list): list of directories to be register for sass watcher.
""" """
for dirname in SASS_LOAD_PATHS + applicable_sass_directories(): for dirname in directories:
paths = [] paths = []
if '*' in dirname: if '*' in dirname:
paths.extend(glob.glob(dirname)) paths.extend(glob.glob(dirname))
...@@ -257,12 +398,133 @@ def compile_coffeescript(*files): ...@@ -257,12 +398,133 @@ def compile_coffeescript(*files):
@no_help @no_help
@cmdopts([ @cmdopts([
('system=', 's', 'The system to compile sass for (defaults to all)'), ('system=', 's', 'The system to compile sass for (defaults to all)'),
('theme-dirs=', '-td', 'Theme dirs containing all themes (defaults to None)'),
('themes=', '-t', 'The theme to compile sass for (defaults to None)'),
('debug', 'd', 'Debug mode'), ('debug', 'd', 'Debug mode'),
('force', '', 'Force full compilation'), ('force', '', 'Force full compilation'),
]) ])
def compile_sass(options): def compile_sass(options):
""" """
Compile Sass to CSS. Compile Sass to CSS. If command is called without any arguments, it will
only compile lms, cms sass for the open source theme. And none of the comprehensive theme's sass would be compiled.
If you want to compile sass for all comprehensive themes you will have to run compile_sass
specifying all the themes that need to be compiled..
The following is a list of some possible ways to use this command.
Command:
paver compile_sass
Description:
compile sass files for both lms and cms. If command is called like above (i.e. without any arguments) it will
only compile lms, cms sass for the open source theme. None of the theme's sass will be compiled.
Command:
paver compile_sass --theme-dirs /edx/app/edxapp/edx-platform/themes --themes=red-theme
Description:
compile sass files for both lms and cms for 'red-theme' present in '/edx/app/edxapp/edx-platform/themes'
Command:
paver compile_sass --theme-dirs=/edx/app/edxapp/edx-platform/themes --themes red-theme stanford-style
Description:
compile sass files for both lms and cms for 'red-theme' and 'stanford-style' present in
'/edx/app/edxapp/edx-platform/themes'.
Command:
paver compile_sass --system=cms
--theme-dirs /edx/app/edxapp/edx-platform/themes /edx/app/edxapp/edx-platform/common/test/
--themes red-theme stanford-style test-theme
Description:
compile sass files for cms only for 'red-theme', 'stanford-style' and 'test-theme' present in
'/edx/app/edxapp/edx-platform/themes' and '/edx/app/edxapp/edx-platform/common/test/'.
"""
debug = options.get('debug')
force = options.get('force')
systems = getattr(options, 'system', ALL_SYSTEMS)
themes = getattr(options, 'themes', [])
theme_dirs = getattr(options, 'theme-dirs', [])
if not theme_dirs and themes:
# We can not compile a theme sass without knowing the directory that contains the theme.
raise ValueError('theme-dirs must be provided for compiling theme sass.')
if isinstance(systems, basestring):
systems = systems.split(',')
else:
systems = systems if isinstance(systems, list) else [systems]
if isinstance(themes, basestring):
themes = themes.split(',')
else:
themes = themes if isinstance(themes, list) else [themes]
if isinstance(theme_dirs, basestring):
theme_dirs = theme_dirs.split(',')
else:
theme_dirs = theme_dirs if isinstance(theme_dirs, list) else [theme_dirs]
if themes and theme_dirs:
themes = get_theme_paths(themes=themes, theme_dirs=theme_dirs)
# Compile sass for OpenEdx theme after comprehensive themes
if None not in themes:
themes.append(None)
timing_info = []
dry_run = tasks.environment.dry_run
compilation_results = {'success': [], 'failure': []}
print("\t\tStarted compiling Sass:")
# compile common sass files
is_successful = _compile_sass('common', None, debug, force, timing_info)
if is_successful:
print("Finished compiling 'common' sass.")
compilation_results['success' if is_successful else 'failure'].append('"common" sass files.')
for system in systems:
for theme in themes:
print("Started compiling '{system}' Sass for '{theme}'.".format(system=system, theme=theme or 'system'))
# Compile sass files
is_successful = _compile_sass(
system=system,
theme=path(theme) if theme else None,
debug=debug,
force=force,
timing_info=timing_info
)
if is_successful:
print("Finished compiling '{system}' Sass for '{theme}'.".format(
system=system, theme=theme or 'system'
))
compilation_results['success' if is_successful else 'failure'].append('{system} sass for {theme}.'.format(
system=system, theme=theme or 'system',
))
print("\t\tFinished compiling Sass:")
if not dry_run:
for sass_dir, css_dir, duration in timing_info:
print(">> {} -> {} in {}s".format(sass_dir, css_dir, duration))
if compilation_results['success']:
print("\033[92m\nSuccessful compilations:\n--- " + "\n--- ".join(compilation_results['success']) + "\n\033[00m")
if compilation_results['failure']:
print("\033[91m\nFailed compilations:\n--- " + "\n--- ".join(compilation_results['failure']) + "\n\033[00m")
def _compile_sass(system, theme, debug, force, timing_info):
"""
Compile sass files for the given system and theme.
:param system: system to compile sass for e.g. 'lms', 'cms', 'common'
:param theme: absolute path of the theme to compile sass for.
:param debug: boolean showing whether to display source comments in resulted css
:param force: boolean showing whether to remove existing css files before generating new files
:param timing_info: list variable to keep track of timing for sass compilation
""" """
# Note: import sass only when it is needed and not at the top of the file. # Note: import sass only when it is needed and not at the top of the file.
...@@ -270,12 +532,14 @@ def compile_sass(options): ...@@ -270,12 +532,14 @@ def compile_sass(options):
# installed. In particular, this allows the install_prereqs command to be # installed. In particular, this allows the install_prereqs command to be
# used to install the dependency. # used to install the dependency.
import sass import sass
if system == "common":
sass_dirs = get_common_sass_directories()
else:
sass_dirs = get_sass_directories(system, theme)
debug = options.get('debug') dry_run = tasks.environment.dry_run
force = options.get('force')
systems = getattr(options, 'system', ALL_SYSTEMS) # determine css out put style and source comments enabling
if isinstance(systems, basestring):
systems = systems.split(',')
if debug: if debug:
source_comments = True source_comments = True
output_style = 'nested' output_style = 'nested'
...@@ -283,13 +547,18 @@ def compile_sass(options): ...@@ -283,13 +547,18 @@ def compile_sass(options):
source_comments = False source_comments = False
output_style = 'compressed' output_style = 'compressed'
timing_info = [] for dirs in sass_dirs:
system_sass_directories = applicable_sass_directories(systems)
all_sass_directories = applicable_sass_directories()
dry_run = tasks.environment.dry_run
for sass_dir in system_sass_directories:
start = datetime.now() start = datetime.now()
css_dir = sass_dir.parent / "css" css_dir = dirs['css_destination_dir']
sass_source_dir = dirs['sass_source_dir']
lookup_paths = dirs['lookup_paths']
if not sass_source_dir.isdir():
print("\033[91m Sass dir '{dir}' does not exists, skipping sass compilation for '{theme}' \033[00m".format(
dir=sass_dirs, theme=theme or system,
))
# theme doesn't override sass directory, so skip it
continue
if force: if force:
if dry_run: if dry_run:
...@@ -301,22 +570,18 @@ def compile_sass(options): ...@@ -301,22 +570,18 @@ def compile_sass(options):
if dry_run: if dry_run:
tasks.environment.info("libsass {sass_dir}".format( tasks.environment.info("libsass {sass_dir}".format(
sass_dir=sass_dir, sass_dir=sass_source_dir,
)) ))
else: else:
sass.compile( sass.compile(
dirname=(sass_dir, css_dir), dirname=(sass_source_dir, css_dir),
include_paths=SASS_LOAD_PATHS + all_sass_directories, include_paths=COMMON_LOOKUP_PATHS + lookup_paths,
source_comments=source_comments, source_comments=source_comments,
output_style=output_style, output_style=output_style,
) )
duration = datetime.now() - start duration = datetime.now() - start
timing_info.append((sass_dir, css_dir, duration)) timing_info.append((sass_source_dir, css_dir, duration))
return True
print("\t\tFinished compiling Sass:")
if not dry_run:
for sass_dir, css_dir, duration in timing_info:
print(">> {} -> {} in {}s".format(sass_dir, css_dir, duration))
def compile_templated_sass(systems, settings): def compile_templated_sass(systems, settings):
...@@ -387,8 +652,36 @@ def collect_assets(systems, settings): ...@@ -387,8 +652,36 @@ def collect_assets(systems, settings):
print("\t\tFinished collecting {} assets.".format(sys)) print("\t\tFinished collecting {} assets.".format(sys))
def execute_compile_sass(args):
"""
Construct django management command compile_sass (defined in theming app) and execute it.
Args:
args: command line argument passed via update_assets command
"""
for sys in args.system:
options = ""
options += " --theme-dirs " + " ".join(args.theme_dirs) if args.theme_dirs else ""
options += " --themes " + " ".join(args.themes) if args.themes else ""
options += " --debug" if args.debug else ""
sh(
django_cmd(
sys,
args.settings,
"compile_sass {system} {options}".format(
system='cms' if sys == 'studio' else sys,
options=options,
),
),
)
@task @task
@cmdopts([('background', 'b', 'Background mode')]) @cmdopts([
('background', 'b', 'Background mode'),
('theme-dirs=', '-td', 'The themes dir containing all themes (defaults to None)'),
('themes=', '-t', 'The themes to add sass watchers for (defaults to None)'),
])
def watch_assets(options): def watch_assets(options):
""" """
Watch for changes to asset files, and regenerate js/css Watch for changes to asset files, and regenerate js/css
...@@ -397,11 +690,26 @@ def watch_assets(options): ...@@ -397,11 +690,26 @@ def watch_assets(options):
if tasks.environment.dry_run: if tasks.environment.dry_run:
return return
themes = getattr(options, 'themes', None)
theme_dirs = getattr(options, 'theme-dirs', [])
if not theme_dirs and themes:
# We can not add theme sass watchers without knowing the directory that contains the themes.
raise ValueError('theme-dirs must be provided for watching theme sass.')
else:
theme_dirs = [path(_dir) for _dir in theme_dirs]
if isinstance(themes, basestring):
themes = themes.split(',')
else:
themes = themes if isinstance(themes, list) else [themes]
sass_directories = get_watcher_dirs(theme_dirs, themes)
observer = PollingObserver() observer = PollingObserver()
CoffeeScriptWatcher().register(observer) CoffeeScriptWatcher().register(observer)
SassWatcher().register(observer) SassWatcher().register(observer, sass_directories)
XModuleSassWatcher().register(observer) XModuleSassWatcher().register(observer, ['common/lib/xmodule/'])
XModuleAssetsWatcher().register(observer) XModuleAssetsWatcher().register(observer)
print("Starting asset watcher...") print("Starting asset watcher...")
...@@ -447,16 +755,29 @@ def update_assets(args): ...@@ -447,16 +755,29 @@ def update_assets(args):
'--watch', action='store_true', default=False, '--watch', action='store_true', default=False,
help="Watch files for changes", help="Watch files for changes",
) )
parser.add_argument(
'--theme-dirs', dest='theme_dirs', type=str, nargs='+', default=None,
help="base directories where themes are placed",
)
parser.add_argument(
'--themes', type=str, nargs='+', default=None,
help="list of themes to compile sass for",
)
args = parser.parse_args(args) args = parser.parse_args(args)
compile_templated_sass(args.system, args.settings) compile_templated_sass(args.system, args.settings)
process_xmodule_assets() process_xmodule_assets()
process_npm_assets() process_npm_assets()
compile_coffeescript() compile_coffeescript()
call_task('pavelib.assets.compile_sass', options={'system': args.system, 'debug': args.debug})
# Compile sass for themes and system
execute_compile_sass(args)
if args.collect: if args.collect:
collect_assets(args.system, args.settings) collect_assets(args.system, args.settings)
if args.watch: if args.watch:
call_task('pavelib.assets.watch_assets', options={'background': not args.debug}) call_task(
'pavelib.assets.watch_assets',
options={'background': not args.debug, 'theme-dirs': args.theme_dirs, 'themes': args.themes},
)
"""Unit tests for the Paver asset tasks.""" """Unit tests for the Paver asset tasks."""
import ddt import ddt
from paver.easy import call_task import os
from unittest import TestCase
from paver.easy import call_task, path
from mock import patch from mock import patch
from watchdog.observers.polling import PollingObserver from watchdog.observers.polling import PollingObserver
from .utils import PaverTestCase from .utils import PaverTestCase
ROOT_PATH = path(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
TEST_THEME = ROOT_PATH / "common/test/test-theme" # pylint: disable=invalid-name
@ddt.ddt @ddt.ddt
class TestPaverAssetTasks(PaverTestCase): class TestPaverAssetTasks(PaverTestCase):
...@@ -43,18 +47,158 @@ class TestPaverAssetTasks(PaverTestCase): ...@@ -43,18 +47,158 @@ class TestPaverAssetTasks(PaverTestCase):
if force: if force:
expected_messages.append("rm -rf common/static/css/*.css") expected_messages.append("rm -rf common/static/css/*.css")
expected_messages.append("libsass common/static/sass") expected_messages.append("libsass common/static/sass")
if "lms" in system: if "lms" in system:
if force: if force:
expected_messages.append("rm -rf lms/static/css/*.css") expected_messages.append("rm -rf lms/static/css/*.css")
expected_messages.append("libsass lms/static/sass") expected_messages.append("libsass lms/static/sass")
if force: if force:
expected_messages.append("rm -rf lms/static/certificates/css/*.css")
expected_messages.append("libsass lms/static/certificates/sass")
if "studio" in system:
if force:
expected_messages.append("rm -rf cms/static/css/*.css")
expected_messages.append("libsass cms/static/sass")
self.assertEquals(self.task_messages, expected_messages)
@ddt.ddt
class TestPaverThemeAssetTasks(PaverTestCase):
"""
Test the Paver asset tasks.
"""
@ddt.data(
[""],
["--force"],
["--debug"],
["--system=lms"],
["--system=lms --force"],
["--system=studio"],
["--system=studio --force"],
["--system=lms,studio"],
["--system=lms,studio --force"],
)
@ddt.unpack
def test_compile_theme_sass(self, options):
"""
Test the "compile_sass" task.
"""
parameters = options.split(" ")
system = []
if "--system=studio" not in parameters:
system += ["lms"]
if "--system=lms" not in parameters:
system += ["studio"]
debug = "--debug" in parameters
force = "--force" in parameters
self.reset_task_messages()
call_task(
'pavelib.assets.compile_sass',
options={"system": system, "debug": debug, "force": force, "theme-dirs": [TEST_THEME.dirname()],
"themes": [TEST_THEME.basename()]},
)
expected_messages = []
if force:
expected_messages.append("rm -rf common/static/css/*.css")
expected_messages.append("libsass common/static/sass")
if "lms" in system:
expected_messages.append("mkdir_p " + repr(TEST_THEME / "lms/static/css"))
if force:
expected_messages.append("rm -rf " + str(TEST_THEME) + "/lms/static/css/*.css")
expected_messages.append("libsass lms/static/sass")
if force:
expected_messages.append("rm -rf " + str(TEST_THEME) + "/lms/static/css/*.css")
expected_messages.append("libsass " + str(TEST_THEME) + "/lms/static/sass")
if force:
expected_messages.append("rm -rf lms/static/css/*.css") expected_messages.append("rm -rf lms/static/css/*.css")
expected_messages.append("libsass lms/static/themed_sass") expected_messages.append("libsass lms/static/sass")
if force: if force:
expected_messages.append("rm -rf lms/static/certificates/css/*.css") expected_messages.append("rm -rf lms/static/certificates/css/*.css")
expected_messages.append("libsass lms/static/certificates/sass") expected_messages.append("libsass lms/static/certificates/sass")
if "studio" in system: if "studio" in system:
expected_messages.append("mkdir_p " + repr(TEST_THEME / "cms/static/css"))
if force:
expected_messages.append("rm -rf " + str(TEST_THEME) + "/cms/static/css/*.css")
expected_messages.append("libsass cms/static/sass")
if force:
expected_messages.append("rm -rf " + str(TEST_THEME) + "/cms/static/css/*.css")
expected_messages.append("libsass " + str(TEST_THEME) + "/cms/static/sass")
if force: if force:
expected_messages.append("rm -rf cms/static/css/*.css") expected_messages.append("rm -rf cms/static/css/*.css")
expected_messages.append("libsass cms/static/sass") expected_messages.append("libsass cms/static/sass")
self.assertEquals(self.task_messages, expected_messages) self.assertEquals(self.task_messages, expected_messages)
class TestPaverWatchAssetTasks(TestCase):
"""
Test the Paver watch asset tasks.
"""
def setUp(self):
self.expected_sass_directories = [
path('common/static/sass'),
path('common/static'),
path('node_modules'),
path('node_modules/edx-pattern-library/node_modules'),
path('lms/static/sass/partials'),
path('lms/static/sass'),
path('lms/static/certificates/sass'),
path('cms/static/sass'),
path('cms/static/sass/partials'),
]
super(TestPaverWatchAssetTasks, self).setUp()
def tearDown(self):
self.expected_sass_directories = []
super(TestPaverWatchAssetTasks, self).tearDown()
def test_watch_assets(self):
"""
Test the "compile_sass" task.
"""
with patch('pavelib.assets.SassWatcher.register') as mock_register:
with patch('pavelib.assets.PollingObserver.start'):
call_task(
'pavelib.assets.watch_assets',
options={"background": True},
)
self.assertEqual(mock_register.call_count, 2)
sass_watcher_args = mock_register.call_args_list[0][0]
self.assertIsInstance(sass_watcher_args[0], PollingObserver)
self.assertIsInstance(sass_watcher_args[1], list)
self.assertItemsEqual(sass_watcher_args[1], self.expected_sass_directories)
def test_watch_theme_assets(self):
"""
Test the Paver watch asset tasks with theming enabled.
"""
self.expected_sass_directories.extend([
path(TEST_THEME) / 'lms/static/sass',
path(TEST_THEME) / 'lms/static/sass/partials',
path(TEST_THEME) / 'cms/static/sass',
path(TEST_THEME) / 'cms/static/sass/partials',
])
with patch('pavelib.assets.SassWatcher.register') as mock_register:
with patch('pavelib.assets.PollingObserver.start'):
call_task(
'pavelib.assets.watch_assets',
options={"background": True, "theme-dirs": [TEST_THEME.dirname()],
"themes": [TEST_THEME.basename()]},
)
self.assertEqual(mock_register.call_count, 2)
sass_watcher_args = mock_register.call_args_list[0][0]
self.assertIsInstance(sass_watcher_args[0], PollingObserver)
self.assertIsInstance(sass_watcher_args[1], list)
self.assertItemsEqual(sass_watcher_args[1], self.expected_sass_directories)
...@@ -17,12 +17,17 @@ EXPECTED_COMMON_SASS_DIRECTORIES = [ ...@@ -17,12 +17,17 @@ EXPECTED_COMMON_SASS_DIRECTORIES = [
] ]
EXPECTED_LMS_SASS_DIRECTORIES = [ EXPECTED_LMS_SASS_DIRECTORIES = [
u"lms/static/sass", u"lms/static/sass",
u"lms/static/themed_sass",
u"lms/static/certificates/sass", u"lms/static/certificates/sass",
] ]
EXPECTED_CMS_SASS_DIRECTORIES = [ EXPECTED_CMS_SASS_DIRECTORIES = [
u"cms/static/sass", u"cms/static/sass",
] ]
EXPECTED_LMS_SASS_COMMAND = [
u"python manage.py lms --settings={asset_settings} compile_sass lms ",
]
EXPECTED_CMS_SASS_COMMAND = [
u"python manage.py cms --settings={asset_settings} compile_sass cms ",
]
EXPECTED_PREPROCESS_ASSETS_COMMAND = ( EXPECTED_PREPROCESS_ASSETS_COMMAND = (
u"python manage.py {system} --settings={asset_settings} preprocess_assets" u"python manage.py {system} --settings={asset_settings} preprocess_assets"
u" {system}/static/sass/*.scss {system}/static/themed_sass" u" {system}/static/sass/*.scss {system}/static/themed_sass"
...@@ -234,7 +239,7 @@ class TestPaverServerTasks(PaverTestCase): ...@@ -234,7 +239,7 @@ class TestPaverServerTasks(PaverTestCase):
expected_messages.append(u"xmodule_assets common/static/xmodule") expected_messages.append(u"xmodule_assets common/static/xmodule")
expected_messages.append(u"install npm_assets") expected_messages.append(u"install npm_assets")
expected_messages.append(EXPECTED_COFFEE_COMMAND.format(platform_root=self.platform_root)) expected_messages.append(EXPECTED_COFFEE_COMMAND.format(platform_root=self.platform_root))
expected_messages.extend(self.expected_sass_commands(system=system)) expected_messages.extend(self.expected_sass_commands(system=system, asset_settings=expected_asset_settings))
if expected_collect_static: if expected_collect_static:
expected_messages.append(EXPECTED_COLLECT_STATIC_COMMAND.format( expected_messages.append(EXPECTED_COLLECT_STATIC_COMMAND.format(
system=system, asset_settings=expected_asset_settings system=system, asset_settings=expected_asset_settings
...@@ -276,7 +281,7 @@ class TestPaverServerTasks(PaverTestCase): ...@@ -276,7 +281,7 @@ class TestPaverServerTasks(PaverTestCase):
expected_messages.append(u"xmodule_assets common/static/xmodule") expected_messages.append(u"xmodule_assets common/static/xmodule")
expected_messages.append(u"install npm_assets") expected_messages.append(u"install npm_assets")
expected_messages.append(EXPECTED_COFFEE_COMMAND.format(platform_root=self.platform_root)) expected_messages.append(EXPECTED_COFFEE_COMMAND.format(platform_root=self.platform_root))
expected_messages.extend(self.expected_sass_commands()) expected_messages.extend(self.expected_sass_commands(asset_settings=expected_asset_settings))
if expected_collect_static: if expected_collect_static:
expected_messages.append(EXPECTED_COLLECT_STATIC_COMMAND.format( expected_messages.append(EXPECTED_COLLECT_STATIC_COMMAND.format(
system="lms", asset_settings=expected_asset_settings system="lms", asset_settings=expected_asset_settings
...@@ -301,14 +306,13 @@ class TestPaverServerTasks(PaverTestCase): ...@@ -301,14 +306,13 @@ class TestPaverServerTasks(PaverTestCase):
expected_messages.append(EXPECTED_CELERY_COMMAND.format(settings="dev_with_worker")) expected_messages.append(EXPECTED_CELERY_COMMAND.format(settings="dev_with_worker"))
self.assertEquals(self.task_messages, expected_messages) self.assertEquals(self.task_messages, expected_messages)
def expected_sass_commands(self, system=None): def expected_sass_commands(self, system=None, asset_settings=u"test_static_optimized"):
""" """
Returns the expected SASS commands for the specified system. Returns the expected SASS commands for the specified system.
""" """
expected_sass_directories = [] expected_sass_commands = []
expected_sass_directories.extend(EXPECTED_COMMON_SASS_DIRECTORIES)
if system != 'cms': if system != 'cms':
expected_sass_directories.extend(EXPECTED_LMS_SASS_DIRECTORIES) expected_sass_commands.extend(EXPECTED_LMS_SASS_COMMAND)
if system != 'lms': if system != 'lms':
expected_sass_directories.extend(EXPECTED_CMS_SASS_DIRECTORIES) expected_sass_commands.extend(EXPECTED_CMS_SASS_COMMAND)
return [EXPECTED_SASS_COMMAND.format(sass_directory=directory) for directory in expected_sass_directories] return [command.format(asset_settings=asset_settings) for command in expected_sass_commands]
...@@ -45,7 +45,7 @@ ...@@ -45,7 +45,7 @@
# Third-party: # Third-party:
git+https://github.com/cyberdelia/django-pipeline.git@1.5.3#egg=django-pipeline==1.5.3 git+https://github.com/cyberdelia/django-pipeline.git@1.5.3#egg=django-pipeline==1.5.3
git+https://github.com/edx/django-wiki.git@v0.0.5#egg=django-wiki==0.0.5 git+https://github.com/edx/django-wiki.git@v0.0.7#egg=django-wiki==0.0.7
git+https://github.com/edx/django-openid-auth.git@0.8#egg=django-openid-auth==0.8 git+https://github.com/edx/django-openid-auth.git@0.8#egg=django-openid-auth==0.8
git+https://github.com/edx/MongoDBProxy.git@25b99097615bda06bd7cdfe5669ed80dc2a7fed0#egg=MongoDBProxy==0.1.0 git+https://github.com/edx/MongoDBProxy.git@25b99097615bda06bd7cdfe5669ed80dc2a7fed0#egg=MongoDBProxy==0.1.0
git+https://github.com/edx/nltk.git@2.0.6#egg=nltk==2.0.6 git+https://github.com/edx/nltk.git@2.0.6#egg=nltk==2.0.6
......
...@@ -133,9 +133,9 @@ directory. There are two ways to do this. ...@@ -133,9 +133,9 @@ directory. There are two ways to do this.
$ sudo /edx/bin/update edx-platform HEAD $ sudo /edx/bin/update edx-platform HEAD
#. Otherwise, edit the /edx/app/edxapp/lms.env.json file to add the #. Otherwise, edit the /edx/app/edxapp/lms.env.json file to add the
``COMPREHENSIVE_THEME_DIR`` value:: ``COMPREHENSIVE_THEME_DIRS`` value::
"COMPREHENSIVE_THEME_DIR": "/full/path/to/my-theme", "COMPREHENSIVE_THEME_DIRS": ["/full/path/to/my-theme"],
Restart your site. Your changes should now be visible. Restart your site. Your changes should now be visible.
......
<%page expression_filter="h"/>
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "login" %></%def>
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
%>
<%block name="title">${_("Sign In")}</%block>
<%block name="bodyclass">not-signedin view-signin</%block>
<%block name="content">
<div class="wrapper-content wrapper">
<section class="content">
<header>
<h1 class="title title-1">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</h1>
<a href="${reverse('signup')}" class="action action-signin">${_("Don't have a {studio_name} Account? Sign up!").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
</header>
<!-- Login Page override for red-theme. -->
<article class="content-primary" role="main">
<form id="login_form" method="post" action="login_post" onsubmit="return false;">
<fieldset>
<legend class="sr">${_("Required Information to Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</legend>
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf }" />
<ol class="list-input">
<li class="field text required" id="field-email">
<label for="email">${_("E-mail")}</label>
<input id="email" type="email" name="email" placeholder="${_('example: username@domain.com')}"/>
</li>
<li class="field text required" id="field-password">
<label for="password">${_("Password")}</label>
<input id="password" type="password" name="password" />
<a href="${forgot_password_link}" class="action action-forgotpassword">${_("Forgot password?")}</a>
</li>
</ol>
</fieldset>
<div class="form-actions">
<button type="submit" id="submit" name="submit" class="action action-primary">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</button>
</div>
<!-- no honor code for CMS, but need it because we're using the lms student object -->
<input name="honor_code" type="checkbox" value="true" checked="true" hidden="true">
</form>
</article>
</section>
</div>
</%block>
<%block name="requirejs">
require(["js/factories/login"], function(LoginFactory) {
LoginFactory("${reverse('homepage')}");
});
</%block>
// Theming overrides for sample theme
@import 'overrides';
// import the rest of the application
@import 'lms/static/sass/lms-main-rtl';
// Theming overrides for sample theme
@import 'overrides';
// import the rest of the application
@import 'lms/static/sass/lms-main';
// Theming overrides for sample theme @import 'lms/static/sass/partials/base/variables';
$header-bg: rgb(250,0,0); $header-bg: rgb(250,0,0);
$footer-bg: rgb(250,0,0); $footer-bg: rgb(250,0,0);
$container-bg: rgb(250,0,0); $container-bg: rgb(250,0,0);
$content-wrapper-bg: rgb(250,0,0);
$serif: 'Comic Sans', 'Comic Sans MS';
$sans-serif: 'Comic Sans', 'Comic Sans MS';
...@@ -72,7 +72,8 @@ from django.utils.translation import ugettext as _ ...@@ -72,7 +72,8 @@ from django.utils.translation import ugettext as _
honor_link = u"<a href='{}'>".format(marketing_link('HONOR')) honor_link = u"<a href='{}'>".format(marketing_link('HONOR'))
%> %>
${ ${
_("{tos_link_start}Terms of Service{tos_link_end} and {honor_link_start}Honor Code{honor_link_end}").format( _(
"{tos_link_start}Terms of Service{tos_link_end} and {honor_link_start}Honor Code{honor_link_end}").format(
tos_link_start=tos_link, tos_link_start=tos_link,
tos_link_end="</a>", tos_link_end="</a>",
honor_link_start=honor_link, honor_link_start=honor_link,
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
<%! <%!
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML
# App that handles subdomain specific branding # App that handles subdomain specific branding
import branding import branding
...@@ -36,7 +37,7 @@ site_status_msg = get_site_status_msg(course_id) ...@@ -36,7 +37,7 @@ site_status_msg = get_site_status_msg(course_id)
% endif % endif
</%block> </%block>
<header id="global-navigation" class="global ${"slim" if course else ""}" > <header id="global-navigation" class="header-global ${"slim" if course else ""}" >
<!-- This file is only for demonstration, and is horrendous! --> <!-- This file is only for demonstration, and is horrendous! -->
<nav aria-label="${_('Global')}"> <nav aria-label="${_('Global')}">
<h1 class="logo"> <h1 class="logo">
...@@ -53,7 +54,7 @@ site_status_msg = get_site_status_msg(course_id) ...@@ -53,7 +54,7 @@ site_status_msg = get_site_status_msg(course_id)
<% <%
display_name = course.display_name_with_default_escaped display_name = course.display_name_with_default_escaped
if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
ccx = get_current_ccx() ccx = get_current_ccx(course.id)
if ccx: if ccx:
display_name = ccx.display_name display_name = ccx.display_name
%> %>
...@@ -152,7 +153,7 @@ site_status_msg = get_site_status_msg(course_id) ...@@ -152,7 +153,7 @@ site_status_msg = get_site_status_msg(course_id)
<li class="nav-courseware-01"> <li class="nav-courseware-01">
% if not settings.FEATURES['DISABLE_LOGIN_BUTTON']: % if not settings.FEATURES['DISABLE_LOGIN_BUTTON']:
% if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: % if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
<a class="btn-brand btn-login" href="${reverse('course-specific-login', args=[course.id.to_deprecated_string()])}${login_query()}">${_("Sign in")}</a> <a class="btn btn-login" href="${reverse('course-specific-login', args=[course.id.to_deprecated_string()])}${login_query()}">${_("Sign in")}</a>
% else: % else:
<a class="btn-brand btn-login" href="/login${login_query()}">${_("Sign in")}</a> <a class="btn-brand btn-login" href="/login${login_query()}">${_("Sign in")}</a>
% endif % endif
...@@ -164,7 +165,7 @@ site_status_msg = get_site_status_msg(course_id) ...@@ -164,7 +165,7 @@ site_status_msg = get_site_status_msg(course_id)
</header> </header>
% if course: % if course:
<!--[if lte IE 8]> <!--[if lte IE 8]>
<div class="ie-banner" aria-hidden="true">${_('<strong>Warning:</strong> Your browser is not fully supported. We strongly recommend using {chrome_link} or {ff_link}.').format(chrome_link='<a href="https://www.google.com/intl/en/chrome/browser/" target="_blank">Chrome</a>', ff_link='<a href="http://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>')}</div> <div class="ie-banner" aria-hidden="true">${_(HTML('<strong>Warning:</strong> Your browser is not fully supported. We strongly recommend using {chrome_link} or {ff_link}.')).format(chrome_link='<a href="https://www.google.com/intl/en/chrome/browser/" target="_blank">Chrome</a>', ff_link='<a href="http://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>')}</div>
<![endif]--> <![endif]-->
% endif % endif
......
// Theming overrides for sample theme
@import 'overrides';
// import the rest of the application
@import 'lms/static/sass/lms-main-rtl';
// Theming overrides for sample theme
@import 'overrides';
// import the rest of the application
@import 'lms/static/sass/lms-main';
@import 'lms/static/sass/partials/base/variables';
// Theming overrides for sample theme // Theming overrides for sample theme
$header-bg: rgb(140,21,21); $header-bg: rgb(140,21,21);
$footer-bg: rgb(140,21,21); $footer-bg: rgb(140,21,21);
......
<%inherit file="main.html" /> <%inherit file="main.html" />
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML
%> %>
<section class="home"> <section class="home">
...@@ -11,7 +12,7 @@ from django.utils.translation import ugettext as _ ...@@ -11,7 +12,7 @@ from django.utils.translation import ugettext as _
% if homepage_overlay_html: % if homepage_overlay_html:
${homepage_overlay_html} ${homepage_overlay_html}
% else: % else:
<h1>${_("Free courses from <strong>{university_name}</strong>").format(university_name="Stanford")}</h1> <h1>${_(HTML("Free courses from <strong>{university_name}</strong>")).format(university_name="Stanford")}</h1>
<p>${_("For anyone, anywhere, anytime")}</p> <p>${_("For anyone, anywhere, anytime")}</p>
% endif % endif
</div> </div>
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from openedx.core.djangolib.js_utils import js_escaped_string
%> %>
<%block name="pagetitle">${_("Preferences for {platform_name}").format(platform_name=settings.PLATFORM_NAME)}</%block> <%block name="pagetitle">${_("Preferences for {platform_name}").format(platform_name=settings.PLATFORM_NAME)}</%block>
...@@ -43,7 +44,7 @@ from django.core.urlresolvers import reverse ...@@ -43,7 +44,7 @@ from django.core.urlresolvers import reverse
}); });
$('#register-form').on('ajax:success', function(event, json, xhr) { $('#register-form').on('ajax:success', function(event, json, xhr) {
var url = json.redirect_url || "${reverse('dashboard')}"; var url = json.redirect_url || "${reverse('dashboard') | n, js_escaped_string }";
location.href = url; location.href = url;
}); });
...@@ -65,14 +66,14 @@ from django.core.urlresolvers import reverse ...@@ -65,14 +66,14 @@ from django.core.urlresolvers import reverse
removeClass('is-disabled'). removeClass('is-disabled').
attr('aria-disabled', false). attr('aria-disabled', false).
removeProp('disabled'). removeProp('disabled').
text("${_('Update my {platform_name} Account').format(platform_name=settings.PLATFORM_NAME)}"); text("${_('Update my {platform_name} Account').format(platform_name=settings.PLATFORM_NAME) | n, js_escaped_string }");
} }
else { else {
$submitButton. $submitButton.
addClass('is-disabled'). addClass('is-disabled').
attr('aria-disabled', true). attr('aria-disabled', true).
prop('disabled', true). prop('disabled', true).
text("${_('Processing your account information')}"); text("${_('Processing your account information') | n, js_escaped_string }");
} }
} }
</script> </script>
......
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