diff --git a/cms/envs/aws.py b/cms/envs/aws.py
index 811bd3b689..090fed25aa 100644
--- a/cms/envs/aws.py
+++ b/cms/envs/aws.py
@@ -211,7 +211,14 @@ ASSET_IGNORE_REGEX = ENV_TOKENS.get('ASSET_IGNORE_REGEX', ASSET_IGNORE_REGEX)
# Theme overrides
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
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py
index 15bd0dee05..da12fb1b29 100644
--- a/cms/envs/bok_choy.py
+++ b/cms/envs/bok_choy.py
@@ -59,9 +59,9 @@ STATIC_URL = "/static/"
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
)
-STATICFILES_DIRS = (
+STATICFILES_DIRS = [
(TEST_ROOT / "staticfiles" / "cms").abspath(),
-)
+]
# Silence noisy logs
import logging
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 0d210366d3..3bbee70add 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -47,7 +47,7 @@ import lms.envs.common
from lms.envs.common import (
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,
- 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
# indirectly accessed through the email opt-in API, which is
# technically accessible through the CMS via legacy URLs.
@@ -61,7 +61,20 @@ from lms.envs.common import (
# Django REST framework configuration
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 warnings import simplefilter
@@ -318,6 +331,7 @@ MIDDLEWARE_CLASSES = (
'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.sites.middleware.CurrentSiteMiddleware',
# Instead of SessionMiddleware, we use a more secure version
# 'django.contrib.sessions.middleware.SessionMiddleware',
@@ -356,6 +370,8 @@ MIDDLEWARE_CLASSES = (
# for expiring inactive sessions
'session_inactivity_timeout.middleware.SessionInactivityTimeout',
+ 'openedx.core.djangoapps.theming.middleware.CurrentSiteThemeMiddleware',
+
# use Django built in clickjacking protection
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
@@ -451,7 +467,6 @@ SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'
# Site info
-SITE_ID = 1
SITE_NAME = "localhost:8001"
HTTPS = 'on'
ROOT_URLCONF = 'cms.urls'
@@ -522,7 +537,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage'
# 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
STATICFILES_FINDERS = [
- 'openedx.core.djangoapps.theming.finders.ComprehensiveThemeFinder',
+ 'openedx.core.djangoapps.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'openedx.core.lib.xblock_pipeline.finder.XBlockPipelineFinder',
diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py
index 044a997cea..966f09b267 100644
--- a/cms/envs/devstack.py
+++ b/cms/envs/devstack.py
@@ -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
STATICFILES_FINDERS = [
- 'openedx.core.djangoapps.theming.finders.ComprehensiveThemeFinder',
+ 'openedx.core.djangoapps.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
]
diff --git a/cms/envs/devstack_optimized.py b/cms/envs/devstack_optimized.py
index dd11b21715..d5796406be 100644
--- a/cms/envs/devstack_optimized.py
+++ b/cms/envs/devstack_optimized.py
@@ -41,6 +41,6 @@ STATIC_URL = "/static/"
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
)
-STATICFILES_DIRS = (
+STATICFILES_DIRS = [
(TEST_ROOT / "staticfiles" / "cms").abspath(),
-)
+]
diff --git a/cms/envs/test.py b/cms/envs/test.py
index aa688eff21..05ca7a2143 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -34,6 +34,7 @@ from lms.envs.test import (
DEFAULT_FILE_STORAGE,
MEDIA_ROOT,
MEDIA_URL,
+ COMPREHENSIVE_THEME_DIRS,
)
# mongo connection settings
@@ -285,6 +286,8 @@ MICROSITE_CONFIGURATION = {
MICROSITE_TEST_HOSTNAME = 'testmicrosite.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
# the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
diff --git a/cms/startup.py b/cms/startup.py
index 4fc4dfce35..f51b3c1325 100644
--- a/cms/startup.py
+++ b/cms/startup.py
@@ -18,7 +18,8 @@ from openedx.core.lib.xblock_utils import xblock_local_resource_url
import xmodule.x_module
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():
@@ -30,8 +31,8 @@ def run():
# Comprehensive theming needs to be set up before django startup,
# because modifying django template paths after startup has no effect.
- if settings.COMPREHENSIVE_THEME_DIR:
- enable_comprehensive_theme(settings.COMPREHENSIVE_THEME_DIR)
+ if is_comprehensive_theming_enabled():
+ enable_theming()
django.setup()
diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/partials/_variables.scss
similarity index 100%
rename from cms/static/sass/_variables.scss
rename to cms/static/sass/partials/_variables.scss
diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py
index 8fa21e0fde..7ca8f6866b 100644
--- a/common/djangoapps/course_modes/tests/test_views.py
+++ b/common/djangoapps/course_modes/tests/test_views.py
@@ -25,6 +25,7 @@ from embargo.test_utils import restrict_course
from student.models import CourseEnrollment
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from util.testing import UrlResetMixin
+from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
@attr('shard_3')
@@ -374,7 +375,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
self.assertEquals(course_modes, expected_modes)
@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):
# Create the course modes
for mode in ["honor", "verified"]:
diff --git a/common/djangoapps/edxmako/makoloader.py b/common/djangoapps/edxmako/makoloader.py
index ba0d70b994..8a21a1d0f1 100644
--- a/common/djangoapps/edxmako/makoloader.py
+++ b/common/djangoapps/edxmako/makoloader.py
@@ -42,10 +42,14 @@ class MakoLoader(object):
def load_template(self, template_name, template_dirs=None):
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"):
# This is a mako template
template = Template(filename=file_path,
- module_directory=self.module_directory,
+ module_directory=module_directory,
input_encoding='utf-8',
output_encoding='utf-8',
default_filters=['decode.utf8'],
diff --git a/common/djangoapps/edxmako/paths.py b/common/djangoapps/edxmako/paths.py
index 3e7bb40430..ed41818f53 100644
--- a/common/djangoapps/edxmako/paths.py
+++ b/common/djangoapps/edxmako/paths.py
@@ -9,9 +9,14 @@ import pkg_resources
from django.conf import settings
from mako.lookup import TemplateLookup
+from mako.exceptions import TopLevelLookupException
-from microsite_configuration import microsite
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):
@@ -49,15 +54,29 @@ class DynamicTemplateLookup(TemplateLookup):
def get_template(self, uri):
"""
- Overridden method which will hand-off the template lookup to the microsite subsystem
- """
- microsite_template = microsite.get_template(uri)
+ Overridden method for locating a template in either the database or the site theme.
- return (
- microsite_template
- if microsite_template
- else super(DynamicTemplateLookup, self).get_template(uri)
- )
+ If not found, template lookup will be done in comprehensive theme for current site
+ by prefixing path to theme.
+ e.g if uri is `main.html` then new uri would be something like this `/red-theme/lms/static/main.html`
+
+ 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):
diff --git a/common/djangoapps/edxmako/shortcuts.py b/common/djangoapps/edxmako/shortcuts.py
index 94cb1cdd01..f1e32138d5 100644
--- a/common/djangoapps/edxmako/shortcuts.py
+++ b/common/djangoapps/edxmako/shortcuts.py
@@ -12,16 +12,18 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from django.template import Context
-from django.http import HttpResponse
import logging
+from django.http import HttpResponse
+from django.template import Context
+
from microsite_configuration import microsite
from edxmako import lookup_template
from edxmako.request_context import get_template_request_context
from django.conf import settings
from django.core.urlresolvers import reverse
+from openedx.core.djangoapps.theming.helpers import get_template_path
log = logging.getLogger(__name__)
@@ -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.
"""
- # see if there is an override template defined in the microsite
- template_name = microsite.get_template_path(template_name)
+ template_name = get_template_path(template_name)
context_instance = Context(dictionary)
# add dictionary to context_instance
diff --git a/common/djangoapps/microsite_configuration/backends/base.py b/common/djangoapps/microsite_configuration/backends/base.py
index a8fec36caa..43743ee935 100644
--- a/common/djangoapps/microsite_configuration/backends/base.py
+++ b/common/djangoapps/microsite_configuration/backends/base.py
@@ -11,7 +11,6 @@ BaseMicrositeTemplateBackend is Base Class for the microsite template backend.
from __future__ import absolute_import
import abc
-import edxmako
import os.path
import threading
@@ -272,9 +271,7 @@ class BaseMicrositeBackend(AbstractBaseMicrositeBackend):
Configure the paths for the microsites feature
"""
microsites_root = settings.MICROSITE_ROOT_DIR
-
if os.path.isdir(microsites_root):
- edxmako.paths.add_lookup('main', microsites_root)
settings.STATICFILES_DIRS.insert(0, microsites_root)
log.info('Loading microsite path at %s', microsites_root)
@@ -292,6 +289,7 @@ class BaseMicrositeBackend(AbstractBaseMicrositeBackend):
microsites_root = settings.MICROSITE_ROOT_DIR
if self.has_configuration_set():
+ settings.MAKO_TEMPLATES['main'].insert(0, microsites_root)
settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].append(microsites_root)
diff --git a/common/djangoapps/microsite_configuration/tests/backends/test_database.py b/common/djangoapps/microsite_configuration/tests/backends/test_database.py
index 43a96cf19d..d643dfe695 100644
--- a/common/djangoapps/microsite_configuration/tests/backends/test_database.py
+++ b/common/djangoapps/microsite_configuration/tests/backends/test_database.py
@@ -105,6 +105,23 @@ class DatabaseMicrositeBackendTests(DatabaseMicrositeTestCase):
microsite.clear()
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')
def test_enable_microsites(self, add_lookup):
"""
@@ -122,7 +139,6 @@ class DatabaseMicrositeBackendTests(DatabaseMicrositeTestCase):
with patch.dict('django.conf.settings.FEATURES', {'USE_MICROSITES': True}):
microsite.enable_microsites(log)
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):
"""
diff --git a/common/djangoapps/pipeline_mako/tests/test_render.py b/common/djangoapps/pipeline_mako/tests/test_render.py
index 1cef001594..4ed13753cb 100644
--- a/common/djangoapps/pipeline_mako/tests/test_render.py
+++ b/common/djangoapps/pipeline_mako/tests/test_render.py
@@ -49,7 +49,7 @@ class PipelineRenderTest(TestCase):
Create static assets once for all pipeline render tests.
"""
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')
@ddt.data(
diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py
index 430b4e503f..2dbd30625e 100644
--- a/common/djangoapps/student/tests/test_email.py
+++ b/common/djangoapps/student/tests/test_email.py
@@ -20,8 +20,8 @@ from django.conf import settings
from edxmako.shortcuts import render_to_string
from util.request import safe_get_host
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.tests.test_util import with_comprehensive_theme
class TestException(Exception):
@@ -99,7 +99,7 @@ class ActivationEmailTests(TestCase):
self._create_account()
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):
self._create_account()
self._assert_activation_email(self.ACTIVATION_SUBJECT, self.EDX_DOMAIN_FRAGMENTS)
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index 018aad8dd8..ba19c37062 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -46,7 +46,6 @@ from certificates.tests.factories import GeneratedCertificateFactory # pylint:
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
import shoppingcart # pylint: disable=import-error
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
from config_models.models import cache
@@ -484,7 +483,6 @@ class DashboardTest(ModuleStoreTestCase):
self.assertEquals(response_2.status_code, 200)
@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):
self.client.login(username="jack", password="test")
response = self.client.get(reverse("dashboard"))
diff --git a/common/test/db_fixtures/sites.json b/common/test/db_fixtures/sites.json
new file mode 100644
index 0000000000..5a7e8bc11b
--- /dev/null
+++ b/common/test/db_fixtures/sites.json
@@ -0,0 +1,20 @@
+[
+ {
+ "pk": 2,
+ "model": "sites.Site",
+
+ "fields": {
+ "domain": "localhost:8003",
+ "name": "lms"
+ }
+ },
+ {
+ "pk": 3,
+ "model": "sites.Site",
+
+ "fields": {
+ "domain": "localhost:8031",
+ "name": "cms"
+ }
+ }
+]
diff --git a/common/test/test-theme/cms/static/css/.gitignore b/common/test/test-theme/cms/static/css/.gitignore
new file mode 100644
index 0000000000..b3a5267117
--- /dev/null
+++ b/common/test/test-theme/cms/static/css/.gitignore
@@ -0,0 +1 @@
+*.css
diff --git a/common/test/test-theme/cms/static/sass/partials/_variables.scss b/common/test/test-theme/cms/static/sass/partials/_variables.scss
new file mode 100644
index 0000000000..4265a16fec
--- /dev/null
+++ b/common/test/test-theme/cms/static/sass/partials/_variables.scss
@@ -0,0 +1,255 @@
+// 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;
diff --git a/common/test/test-theme/cms/templates/login.html b/common/test/test-theme/cms/templates/login.html
new file mode 100644
index 0000000000..a5faf86f1c
--- /dev/null
+++ b/common/test/test-theme/cms/templates/login.html
@@ -0,0 +1,58 @@
+<%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">
+
+%block>
+
+<%block name="requirejs">
+ require(["js/factories/login"], function(LoginFactory) {
+ LoginFactory("${reverse('homepage') | n, js_escaped_string }");
+ });
+%block>
diff --git a/common/test/test-theme/lms/static/css/.gitignore b/common/test/test-theme/lms/static/css/.gitignore
new file mode 100644
index 0000000000..b3a5267117
--- /dev/null
+++ b/common/test/test-theme/lms/static/css/.gitignore
@@ -0,0 +1 @@
+*.css
diff --git a/common/test/test-theme/lms/static/images/logo.png b/common/test/test-theme/lms/static/images/logo.png
new file mode 100644
index 0000000000..5efc6b63a4
Binary files /dev/null and b/common/test/test-theme/lms/static/images/logo.png differ
diff --git a/common/test/test-theme/lms/static/sass/partials/base/_variables.scss b/common/test/test-theme/lms/static/sass/partials/base/_variables.scss
new file mode 100755
index 0000000000..43f66799a0
--- /dev/null
+++ b/common/test/test-theme/lms/static/sass/partials/base/_variables.scss
@@ -0,0 +1,5 @@
+@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);
diff --git a/common/test/test-theme/lms/templates/footer.html b/common/test/test-theme/lms/templates/footer.html
new file mode 100644
index 0000000000..6f12ccfd55
--- /dev/null
+++ b/common/test/test-theme/lms/templates/footer.html
@@ -0,0 +1,12 @@
+<%page expression_filter="h"/>
+
+
diff --git a/lms/djangoapps/branding/tests/test_views.py b/lms/djangoapps/branding/tests/test_views.py
index eb62bf2282..991f528809 100644
--- a/lms/djangoapps/branding/tests/test_views.py
+++ b/lms/djangoapps/branding/tests/test_views.py
@@ -10,7 +10,7 @@ import mock
import ddt
from config_models.models import cache
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
@@ -30,19 +30,19 @@ class TestFooter(TestCase):
@ddt.data(
# Open source version
- (False, "application/json", "application/json; charset=utf-8", "Open edX"),
- (False, "text/html", "text/html; charset=utf-8", "lms-footer.css"),
- (False, "text/html", "text/html; charset=utf-8", "Open edX"),
+ (None, "application/json", "application/json; charset=utf-8", "Open edX"),
+ (None, "text/html", "text/html; charset=utf-8", "lms-footer.css"),
+ (None, "text/html", "text/html; charset=utf-8", "Open edX"),
# EdX.org version
- (True, "application/json", "application/json; charset=utf-8", "edX Inc"),
- (True, "text/html", "text/html; charset=utf-8", "lms-footer-edx.css"),
- (True, "text/html", "text/html; charset=utf-8", "edX Inc"),
+ ("edx.org", "application/json", "application/json; charset=utf-8", "edX Inc"),
+ ("edx.org", "text/html", "text/html; charset=utf-8", "lms-footer-edx.css"),
+ ("edx.org", "text/html", "text/html; charset=utf-8", "edX Inc"),
)
@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)
- with with_edx_domain_context(is_edx_domain):
+ with with_comprehensive_theme_context(theme):
resp = self._get_footer(accepts=accepts)
self.assertEqual(resp.status_code, 200)
@@ -50,10 +50,10 @@ class TestFooter(TestCase):
self.assertIn(content, resp.content)
@mock.patch.dict(settings.FEATURES, {'ENABLE_FOOTER_MOBILE_APP_LINKS': True})
- @ddt.data(True, False)
- def test_footer_json(self, is_edx_domain):
+ @ddt.data("edx.org", None)
+ def test_footer_json(self, theme):
self._set_feature_flag(True)
- with with_edx_domain_context(is_edx_domain):
+ with with_comprehensive_theme_context(theme):
resp = self._get_footer()
self.assertEqual(resp.status_code, 200)
@@ -142,18 +142,18 @@ class TestFooter(TestCase):
@ddt.data(
# OpenEdX
- (False, "en", "lms-footer.css"),
- (False, "ar", "lms-footer-rtl.css"),
+ (None, "en", "lms-footer.css"),
+ (None, "ar", "lms-footer-rtl.css"),
# EdX.org
- (True, "en", "lms-footer-edx.css"),
- (True, "ar", "lms-footer-edx-rtl.css"),
+ ("edx.org", "en", "lms-footer-edx.css"),
+ ("edx.org", "ar", "lms-footer-edx-rtl.css"),
)
@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)
- with with_edx_domain_context(is_edx_domain):
+ with with_comprehensive_theme_context(theme):
resp = self._get_footer(accepts="text/html", params={'language': language})
self.assertEqual(resp.status_code, 200)
@@ -161,18 +161,18 @@ class TestFooter(TestCase):
@ddt.data(
# OpenEdX
- (False, True),
- (False, False),
+ (None, True),
+ (None, False),
# EdX.org
- (True, True),
- (True, False),
+ ("edx.org", True),
+ ("edx.org", False),
)
@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)
- with with_edx_domain_context(is_edx_domain):
+ with with_comprehensive_theme_context(theme):
params = {'show-openedx-logo': 1} if show_logo else {}
resp = self._get_footer(accepts="text/html", params=params)
@@ -185,17 +185,17 @@ class TestFooter(TestCase):
@ddt.data(
# OpenEdX
- (False, False),
- (False, True),
+ (None, False),
+ (None, True),
# EdX.org
- (True, False),
- (True, True),
+ ("edx.org", False),
+ ("edx.org", True),
)
@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)
- with with_edx_domain_context(is_edx_domain):
+ with with_comprehensive_theme_context(theme):
params = {'include-dependencies': 1} if include_dependencies else {}
resp = self._get_footer(accepts="text/html", params=params)
diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py
index b28fd7a84b..50ab358d89 100644
--- a/lms/djangoapps/commerce/tests/test_views.py
+++ b/lms/djangoapps/commerce/tests/test_views.py
@@ -8,7 +8,7 @@ from django.test import TestCase
import mock
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):
@@ -85,7 +85,7 @@ class ReceiptViewTests(UserMixin, TestCase):
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)
- @with_is_edx_domain(True)
+ @with_comprehensive_theme("edx.org")
def test_hide_nav_header(self):
self._login()
post_data = {'decision': 'ACCEPT', 'reason_code': '200', 'signed_field_names': 'dummy'}
diff --git a/lms/djangoapps/course_wiki/tests/test_comprehensive_theming.py b/lms/djangoapps/course_wiki/tests/test_comprehensive_theming.py
index e32af4991f..375415b252 100644
--- a/lms/djangoapps/course_wiki/tests/test_comprehensive_theming.py
+++ b/lms/djangoapps/course_wiki/tests/test_comprehensive_theming.py
@@ -1,7 +1,6 @@
"""
Tests for wiki middleware.
"""
-from django.conf import settings
from django.test.client import Client
from nose.plugins.attrib import attr
from unittest import skip
@@ -35,7 +34,7 @@ class TestComprehensiveTheming(ModuleStoreTestCase):
self.client.login(username='instructor', password='secret')
@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):
"""
Tests that theme footer is used rather than standard
diff --git a/lms/djangoapps/course_wiki/views.py b/lms/djangoapps/course_wiki/views.py
index f1681a4e7f..c930ef8fd8 100644
--- a/lms/djangoapps/course_wiki/views.py
+++ b/lms/djangoapps/course_wiki/views.py
@@ -6,8 +6,6 @@ import re
import cgi
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.utils.translation import ugettext as _
@@ -51,21 +49,6 @@ def course_wiki_redirect(request, course_id): # pylint: disable=unused-argument
if not valid_slug:
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:
urlpath = URLPath.get_by_path(course_slug, select_related=True)
diff --git a/lms/djangoapps/courseware/tests/test_comprehensive_theming.py b/lms/djangoapps/courseware/tests/test_comprehensive_theming.py
index ee28177a15..e6702ab9ea 100644
--- a/lms/djangoapps/courseware/tests/test_comprehensive_theming.py
+++ b/lms/djangoapps/courseware/tests/test_comprehensive_theming.py
@@ -7,7 +7,7 @@ from path import path # pylint: disable=no-name-in-module
from django.contrib import staticfiles
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):
@@ -19,8 +19,13 @@ class TestComprehensiveTheming(TestCase):
# Clear the internal staticfiles caches, to get test isolation.
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):
+ """
+ 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('/')
self.assertEqual(resp.status_code, 200)
# This string comes from footer.html
@@ -34,12 +39,16 @@ class TestComprehensiveTheming(TestCase):
# of test.
# Make a temp directory as a theme.
- tmp_theme = path(mkdtemp_clean())
- template_dir = tmp_theme / "lms/templates"
+ themes_dir = path(mkdtemp_clean())
+ tmp_theme = "temp_theme"
+ template_dir = themes_dir / tmp_theme / "lms/templates"
template_dir.makedirs()
with open(template_dir / "footer.html", "w") as footer:
footer.write("")
+ dest_path = path(settings.COMPREHENSIVE_THEME_DIRS[0]) / tmp_theme
+ create_symlink(themes_dir / tmp_theme, dest_path)
+
@with_comprehensive_theme(tmp_theme)
def do_the_test(self):
"""A function to do the work so we can use the decorator."""
@@ -48,28 +57,16 @@ class TestComprehensiveTheming(TestCase):
self.assertContains(resp, "TEMPORARY THEME")
do_the_test(self)
-
- def test_theme_adjusts_staticfiles_search_path(self):
- # 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)
+ # remove symlinks before running subsequent tests
+ delete_symlink(dest_path)
def test_default_logo_image(self):
result = staticfiles.finders.find('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):
- 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')
def test_default_favicon(self):
@@ -79,10 +76,10 @@ class TestComprehensiveTheming(TestCase):
result = staticfiles.finders.find('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):
"""
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')
diff --git a/lms/djangoapps/courseware/tests/test_course_info.py b/lms/djangoapps/courseware/tests/test_course_info.py
index b02dd52538..ccb7ebacca 100644
--- a/lms/djangoapps/courseware/tests/test_course_info.py
+++ b/lms/djangoapps/courseware/tests/test_course_info.py
@@ -312,11 +312,12 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest
url = reverse('info', args=[unicode(course.id)])
with self.assertNumQueries(sql_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)
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):
- self.fetch_course_info_with_queries(self.self_paced_course, 21, 4)
+ self.fetch_course_info_with_queries(self.self_paced_course, 22, 4)
diff --git a/lms/djangoapps/courseware/tests/test_footer.py b/lms/djangoapps/courseware/tests/test_footer.py
index 44cc93cbdd..1f8c2aa00e 100644
--- a/lms/djangoapps/courseware/tests/test_footer.py
+++ b/lms/djangoapps/courseware/tests/test_footer.py
@@ -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.
"""
+import unittest
from nose.plugins.attrib import attr
from django.conf import settings
from django.test import TestCase
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')
class TestFooter(TestCase):
+ """
+ Tests for edx and OpenEdX footer
+ """
SOCIAL_MEDIA_NAMES = [
"facebook",
@@ -37,7 +42,7 @@ class TestFooter(TestCase):
"youtube": "https://www.youtube.com/"
}
- @with_is_edx_domain(True)
+ @with_comprehensive_theme("edx.org")
def test_edx_footer(self):
"""
Verify that the homepage, when accessed at edx.org, has the edX footer
@@ -46,7 +51,6 @@ class TestFooter(TestCase):
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'footer-edx-v3')
- @with_is_edx_domain(False)
def test_openedx_footer(self):
"""
Verify that the homepage, when accessed at something other than
@@ -56,7 +60,7 @@ class TestFooter(TestCase):
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'footer-openedx')
- @with_is_edx_domain(True)
+ @with_comprehensive_theme("edx.org")
@override_settings(
SOCIAL_MEDIA_FOOTER_NAMES=SOCIAL_MEDIA_NAMES,
SOCIAL_MEDIA_FOOTER_URLS=SOCIAL_MEDIA_URLS
diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py
index 258e20b3b6..04254c8da0 100644
--- a/lms/djangoapps/courseware/tests/test_views.py
+++ b/lms/djangoapps/courseware/tests/test_views.py
@@ -1340,7 +1340,7 @@ class ProgressPageTests(ModuleStoreTestCase):
self.assertContains(resp, u"Download Your Certificate")
@ddt.data(
- *itertools.product(((46, 4, True), (46, 4, False)), (True, False))
+ *itertools.product(((47, 4, True), (47, 4, False)), (True, False))
)
@ddt.unpack
def test_query_counts(self, (sql_calls, mongo_calls, self_paced), self_paced_enabled):
diff --git a/lms/djangoapps/django_comment_client/base/tests.py b/lms/djangoapps/django_comment_client/base/tests.py
index bbce7dc065..03d823c8e4 100644
--- a/lms/djangoapps/django_comment_client/base/tests.py
+++ b/lms/djangoapps/django_comment_client/base/tests.py
@@ -372,8 +372,8 @@ class ViewsQueryCountTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet
return inner
@ddt.data(
- (ModuleStoreEnum.Type.mongo, 3, 4, 30),
- (ModuleStoreEnum.Type.split, 3, 13, 30),
+ (ModuleStoreEnum.Type.mongo, 3, 4, 31),
+ (ModuleStoreEnum.Type.split, 3, 13, 31),
)
@ddt.unpack
@count_queries
@@ -381,8 +381,8 @@ class ViewsQueryCountTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet
self.create_thread_helper(mock_request)
@ddt.data(
- (ModuleStoreEnum.Type.mongo, 3, 3, 24),
- (ModuleStoreEnum.Type.split, 3, 10, 24),
+ (ModuleStoreEnum.Type.mongo, 3, 3, 25),
+ (ModuleStoreEnum.Type.split, 3, 10, 25),
)
@ddt.unpack
@count_queries
diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py
index c410630010..b47b02de0c 100644
--- a/lms/djangoapps/student_account/test/test_views.py
+++ b/lms/djangoapps/student_account/test/test_views.py
@@ -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.djangolib.js_utils import dump_js_escaped_json
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_account.views import account_settings_context, get_user_orders
from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context
@ddt.ddt
@@ -262,13 +262,13 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
self.assertRedirects(response, reverse("dashboard"))
@ddt.data(
- (False, "signin_user"),
- (False, "register_user"),
- (True, "signin_user"),
- (True, "register_user"),
+ (None, "signin_user"),
+ (None, "register_user"),
+ ("edx.org", "signin_user"),
+ ("edx.org", "register_user"),
)
@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 = [
('course_id', 'edX/DemoX/Demo_Course'),
('enrollment_action', 'enroll'),
@@ -276,7 +276,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
# The response should have a "Sign In" button with the URL
# 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)
expected_url = '/login?{}'.format(self._finish_auth_url_param(params + [('next', '/dashboard')]))
@@ -292,7 +292,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
]
# 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)
expected_url = '/login?{}'.format(self._finish_auth_url_param(params))
diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py
index d5231e2d48..2044fab825 100644
--- a/lms/djangoapps/verify_student/tests/test_views.py
+++ b/lms/djangoapps/verify_student/tests/test_views.py
@@ -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 embargo.test_utils import restrict_course
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 student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment
@@ -321,7 +321,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin):
)
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")
def test_pay_and_verify_hides_header_nav(self, payment_flow):
course = self._create_course("verified")
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 840daaad0f..29d264576a 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -260,7 +260,14 @@ BULK_EMAIL_ROUTING_KEY_SMALL_JOBS = LOW_PRIORITY_QUEUE
# Theme overrides
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
MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {}))
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index 70e69196a4..047bf8d27b 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -61,9 +61,9 @@ STATIC_URL = "/static/"
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
)
-STATICFILES_DIRS = (
+STATICFILES_DIRS = [
(TEST_ROOT / "staticfiles" / "lms").abspath(),
-)
+]
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
MEDIA_ROOT = TEST_ROOT / "uploads"
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 4913dcb83a..b8be3cea7b 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -387,9 +387,6 @@ COURSES_ROOT = ENV_ROOT / "data"
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
sys.path.append(REPO_ROOT)
sys.path.append(PROJECT_ROOT / 'djangoapps')
@@ -489,6 +486,7 @@ TEMPLATES = [
'loaders': [
# We have to use mako-aware template loaders to be able to include
# mako templates inside django templates (such as main_django.html).
+ 'openedx.core.djangoapps.theming.template_loaders.ThemeTemplateLoader',
'edxmako.makoloader.MakoFilesystemLoader',
'edxmako.makoloader.MakoAppDirectoriesLoader',
],
@@ -784,7 +782,6 @@ SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'
CMS_BASE = 'localhost:8001'
# Site info
-SITE_ID = 1
SITE_NAME = "example.com"
HTTPS = 'on'
ROOT_URLCONF = 'lms.urls'
@@ -1166,6 +1163,8 @@ MIDDLEWARE_CLASSES = (
'course_wiki.middleware.WikiAccessMiddleware',
+ 'openedx.core.djangoapps.theming.middleware.CurrentSiteThemeMiddleware',
+
# This must be last
'microsite_configuration.middleware.MicrositeSessionCookieDomainMiddleware',
)
@@ -1185,7 +1184,7 @@ STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage'
# 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
STATICFILES_FINDERS = [
- 'openedx.core.djangoapps.theming.finders.ComprehensiveThemeFinder',
+ 'openedx.core.djangoapps.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'openedx.core.lib.xblock_pipeline.finder.XBlockPipelineFinder',
@@ -2920,6 +2919,21 @@ CREDENTIALS_GENERATION_ROUTING_KEY = HIGH_PRIORITY_QUEUE
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_MANAGER_EMAIL = 'api-access@example.com'
API_ACCESS_FROM_EMAIL = 'api-requests@example.com'
diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py
index 259d4e8a04..13c5007dbf 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -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
STATICFILES_FINDERS = [
- 'openedx.core.djangoapps.theming.finders.ComprehensiveThemeFinder',
+ 'openedx.core.djangoapps.theming.finders.ThemeFilesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
]
diff --git a/lms/envs/devstack_optimized.py b/lms/envs/devstack_optimized.py
index ad8ddf00bd..cea9b31b49 100644
--- a/lms/envs/devstack_optimized.py
+++ b/lms/envs/devstack_optimized.py
@@ -41,6 +41,6 @@ STATIC_URL = "/static/"
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
)
-STATICFILES_DIRS = (
+STATICFILES_DIRS = [
(TEST_ROOT / "staticfiles" / "lms").abspath(),
-)
+]
diff --git a/lms/envs/test.py b/lms/envs/test.py
index d000668a12..cdff03bfea 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -494,6 +494,8 @@ MICROSITE_CONFIGURATION = {
MICROSITE_TEST_HOSTNAME = 'testmicrosite.testserver'
MICROSITE_LOGISTRATION_HOSTNAME = 'logistration.testserver'
+TEST_THEME = COMMON_ROOT / "test" / "test-theme"
+
# add extra template directory for test-only templates
MAKO_TEMPLATES['main'].extend([
COMMON_ROOT / 'test' / 'templates',
@@ -582,3 +584,5 @@ JWT_AUTH.update({
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
COURSE_CATALOG_API_URL = 'https://catalog.example.com/api/v1'
+
+COMPREHENSIVE_THEME_DIRS = [REPO_ROOT / "themes", REPO_ROOT / "common/test"]
diff --git a/lms/startup.py b/lms/startup.py
index b6e63c3232..a88b786c9d 100644
--- a/lms/startup.py
+++ b/lms/startup.py
@@ -20,7 +20,9 @@ from monkey_patch import (
import xmodule.x_module
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
log = logging.getLogger(__name__)
@@ -39,8 +41,8 @@ def run():
# Comprehensive theming needs to be set up before django startup,
# because modifying django template paths after startup has no effect.
- if settings.COMPREHENSIVE_THEME_DIR:
- enable_comprehensive_theme(settings.COMPREHENSIVE_THEME_DIR)
+ if is_comprehensive_theming_enabled():
+ enable_theming()
# We currently use 2 template rendering engines, mako and django_templates,
# and one of them (django templates), requires the directories be added
diff --git a/lms/static/sass/discussion/_build.scss b/lms/static/sass/discussion/_build.scss
index d57c796f05..f04d7faf40 100644
--- a/lms/static/sass/discussion/_build.scss
+++ b/lms/static/sass/discussion/_build.scss
@@ -6,7 +6,7 @@ $static-path: '../..' !default;
// Configuration
@import '../config';
-@import '../base/variables';
+@import 'base/variables';
@import '../base-v2/extends';
// Common extensions
diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/partials/base/_variables.scss
similarity index 100%
rename from lms/static/sass/base/_variables.scss
rename to lms/static/sass/partials/base/_variables.scss
diff --git a/lms/templates/main_django.html b/lms/templates/main_django.html
index b2dd8e6672..0d24d3c879 100644
--- a/lms/templates/main_django.html
+++ b/lms/templates/main_django.html
@@ -1,5 +1,5 @@
-{% load sekizai_tags i18n microsite pipeline optional_include staticfiles %}
+{% load sekizai_tags i18n microsite theme_pipeline optional_include staticfiles %}
{% load url from future %}
diff --git a/lms/templates/wiki/base.html b/lms/templates/wiki/base.html
index f19e6c20b8..78d3cf4a92 100644
--- a/lms/templates/wiki/base.html
+++ b/lms/templates/wiki/base.html
@@ -1,6 +1,6 @@
{% extends "main_django.html" %}
{% 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 pagetitle %}{% endblock %} | {% trans "Wiki" %} | {% platform_name %}
diff --git a/lms/templates/wiki/preview_inline.html b/lms/templates/wiki/preview_inline.html
index 75f57e9603..a2e44b3526 100644
--- a/lms/templates/wiki/preview_inline.html
+++ b/lms/templates/wiki/preview_inline.html
@@ -1,5 +1,5 @@
-{% load wiki_tags i18n %}{% load pipeline %}
+{% load wiki_tags i18n %}{% load theme_pipeline %}
{% stylesheet 'course' %}
diff --git a/openedx/core/djangoapps/bookmarks/tests/test_views.py b/openedx/core/djangoapps/bookmarks/tests/test_views.py
index 6945bce77c..76acc66be3 100644
--- a/openedx/core/djangoapps/bookmarks/tests/test_views.py
+++ b/openedx/core/djangoapps/bookmarks/tests/test_views.py
@@ -268,7 +268,7 @@ class BookmarksListViewTests(BookmarksViewsTestsBase):
self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.')
# 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(
client=self.client,
url=reverse('bookmarks'),
diff --git a/openedx/core/djangoapps/theming/admin.py b/openedx/core/djangoapps/theming/admin.py
new file mode 100644
index 0000000000..690016f8c8
--- /dev/null
+++ b/openedx/core/djangoapps/theming/admin.py
@@ -0,0 +1,22 @@
+"""
+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)
diff --git a/openedx/core/djangoapps/theming/core.py b/openedx/core/djangoapps/theming/core.py
index 9203cc5c1f..27865f2efc 100644
--- a/openedx/core/djangoapps/theming/core.py
+++ b/openedx/core/djangoapps/theming/core.py
@@ -1,62 +1,29 @@
"""
Core logic for Comprehensive Theming.
"""
-from path import Path
-
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
+
+
+def enable_theming():
"""
- 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.
-
+ Add directories and relevant paths to settings for comprehensive theming.
"""
+ # Deprecated Warnings
+ if hasattr(settings, "COMPREHENSIVE_THEME_DIR"):
+ logger.warning(
+ "\033[93m \nDeprecated: "
+ "\n\tCOMPREHENSIVE_THEME_DIR setting has been deprecated in favor of COMPREHENSIVE_THEME_DIRS.\033[00m"
+ )
- changes = {
- 'settings': {},
- 'template_paths': [],
- }
- root = Path(settings.PROJECT_ROOT)
- if root.name == "":
- root = root.parent
+ for theme in get_themes():
+ locale_dir = theme.path / "conf" / "locale"
+ if locale_dir.isdir():
+ settings.LOCALE_PATHS = (locale_dir, ) + settings.LOCALE_PATHS
- 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)
-
- # Use the changes
- for name, value in changes['settings'].iteritems():
- setattr(settings, name, value)
- for template_dir in changes['template_paths']:
- settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].insert(0, template_dir)
- settings.MAKO_TEMPLATES['main'].insert(0, template_dir)
+ if theme.themes_base_dir not in settings.MAKO_TEMPLATES['main']:
+ settings.MAKO_TEMPLATES['main'].insert(0, theme.themes_base_dir)
diff --git a/openedx/core/djangoapps/theming/finders.py b/openedx/core/djangoapps/theming/finders.py
index cbf4366f5a..9bf300af31 100644
--- a/openedx/core/djangoapps/theming/finders.py
+++ b/openedx/core/djangoapps/theming/finders.py
@@ -17,63 +17,80 @@ interface, as well.
.. _Django-Pipeline: http://django-pipeline.readthedocs.org/
.. _Django-Require: https://github.com/etianen/django-require
"""
-from path import Path
-from django.conf import settings
-from django.core.exceptions import ImproperlyConfigured
+import os
+from collections import OrderedDict
+
from django.contrib.staticfiles import utils
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
- for static files. If the ``COMPREHENSIVE_THEME_DIR`` setting is unset,
- or the ``COMPREHENSIVE_THEME_DIR`` does not exist on the file system,
- this finder will never find any files.
+ A static files finder that looks in the directory of each theme as
+ specified in the source_dir attribute.
"""
+ storage_class = ThemeStorage
+ source_dir = 'static'
+
def __init__(self, *args, **kwargs):
- 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", "")
- if not theme_dir:
- self.storage = None
- return
+ themes = get_themes()
+ for theme in themes:
+ theme_storage = self.storage_class(
+ os.path.join(theme.path, self.source_dir),
+ prefix=theme.theme_dir_name,
+ )
- if not isinstance(theme_dir, basestring):
- raise ImproperlyConfigured("Your COMPREHENSIVE_THEME_DIR setting must be a string")
+ self.storages[theme.theme_dir_name] = theme_storage
+ if theme.theme_dir_name not in self.themes:
+ self.themes.append(theme.theme_dir_name)
- root = Path(settings.PROJECT_ROOT)
- if root.name == "":
- root = root.parent
-
- component_dir = Path(theme_dir) / root.name
- static_dir = component_dir / "static"
- self.storage = CachedComprehensiveThemingStorage(location=static_dir)
-
- def find(self, path, all=False): # pylint: disable=redefined-builtin
- """
- Looks for files in the default file storage, if it's local.
- """
- if not self.storage:
- return []
-
- if path.startswith(self.storage.prefix):
- # strip the prefix
- path = path[len(self.storage.prefix):]
-
- if self.storage.exists(path):
- match = self.storage.path(path)
- if all:
- match = [match]
- return match
-
- return []
+ super(ThemeFilesFinder, self).__init__(*args, **kwargs)
def list(self, ignore_patterns):
"""
- List all files of the storage.
+ List all files in all app storages.
"""
- if self.storage and self.storage.exists(''):
- for path in utils.get_files(self.storage, ignore_patterns):
- yield path, self.storage
+ for storage in six.itervalues(self.storages):
+ if storage.exists(''): # check if storage location exists
+ for path in utils.get_files(storage, ignore_patterns):
+ yield path, storage
+
+ def find(self, path, all=False): # pylint: disable=redefined-builtin
+ """
+ Looks for files in the theme directories.
+ """
+ matches = []
+ theme_dir_name = path.split("/", 1)[0]
+
+ themes = {t.theme_dir_name: t for t in get_themes()}
+ # if path is prefixed by theme name then search in the corresponding storage other wise search all storages.
+ if theme_dir_name in themes:
+ theme = themes[theme_dir_name]
+ 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
+
+ def find_in_theme(self, theme, path):
+ """
+ Find a requested static file in an theme's static locations.
+ """
+ storage = self.storages.get(theme, None)
+ if storage:
+ # only try to find a file if the source dir actually exists
+ if storage.exists(path):
+ matched_path = storage.path(path)
+ if matched_path:
+ return matched_path
diff --git a/openedx/core/djangoapps/theming/helpers.py b/openedx/core/djangoapps/theming/helpers.py
index 82434934cb..24eee891a0 100644
--- a/openedx/core/djangoapps/theming/helpers.py
+++ b/openedx/core/djangoapps/theming/helpers.py
@@ -1,10 +1,20 @@
"""
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 logging import getLogger
+logger = getLogger(__name__) # pylint: disable=invalid-name
+
def get_page_title_breadcrumbs(*args):
"""
@@ -42,7 +52,9 @@ def get_template_path(relative_path, **kwargs):
"""
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():
@@ -52,6 +64,14 @@ def is_request_in_themed_site():
return microsite.is_request_in_microsite()
+def get_template(uri):
+ """
+ This is a proxy function to hide microsite_configuration behind comprehensive theming.
+ :param uri: uri of the template
+ """
+ return microsite.get_template(uri)
+
+
def get_themed_template_path(relative_path, default_path, **kwargs):
"""
This is a proxy function to hide microsite_configuration behind comprehensive theming.
@@ -70,3 +90,401 @@ def get_themed_template_path(relative_path, default_path, **kwargs):
if is_stanford_theming_enabled and not is_microsite:
return relative_path
return microsite.get_template_path(default_path, **kwargs)
+
+
+def get_template_path_with_theme(relative_path):
+ """
+ Returns template path in current site's theme if it finds one there otherwise returns same path.
+
+ Example:
+ >> get_template_path_with_theme('header.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"".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',
+ ]
diff --git a/openedx/core/djangoapps/theming/management/__init__.py b/openedx/core/djangoapps/theming/management/__init__.py
new file mode 100644
index 0000000000..61f0a0f54c
--- /dev/null
+++ b/openedx/core/djangoapps/theming/management/__init__.py
@@ -0,0 +1,3 @@
+"""
+Management commands related to Comprehensive Theming.
+"""
diff --git a/openedx/core/djangoapps/theming/management/commands/__init__.py b/openedx/core/djangoapps/theming/management/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/theming/management/commands/compile_sass.py b/openedx/core/djangoapps/theming/management/commands/compile_sass.py
new file mode 100644
index 0000000000..8202f5d2c3
--- /dev/null
+++ b/openedx/core/djangoapps/theming/management/commands/compile_sass.py
@@ -0,0 +1,139 @@
+"""
+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},
+ )
diff --git a/openedx/core/djangoapps/theming/middleware.py b/openedx/core/djangoapps/theming/middleware.py
new file mode 100644
index 0000000000..614a355b7d
--- /dev/null
+++ b/openedx/core/djangoapps/theming/middleware.py
@@ -0,0 +1,21 @@
+"""
+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)
diff --git a/openedx/core/djangoapps/theming/migrations/0001_initial.py b/openedx/core/djangoapps/theming/migrations/0001_initial.py
new file mode 100644
index 0000000000..ebf80f9d3e
--- /dev/null
+++ b/openedx/core/djangoapps/theming/migrations/0001_initial.py
@@ -0,0 +1,22 @@
+# -*- 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')),
+ ],
+ ),
+ ]
diff --git a/openedx/core/djangoapps/theming/migrations/__init__.py b/openedx/core/djangoapps/theming/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/theming/models.py b/openedx/core/djangoapps/theming/models.py
new file mode 100644
index 0000000000..d6e2e43b10
--- /dev/null
+++ b/openedx/core/djangoapps/theming/models.py
@@ -0,0 +1,39 @@
+"""
+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
diff --git a/openedx/core/djangoapps/theming/paver_helpers.py b/openedx/core/djangoapps/theming/paver_helpers.py
new file mode 100644
index 0000000000..15eae72368
--- /dev/null
+++ b/openedx/core/djangoapps/theming/paver_helpers.py
@@ -0,0 +1,73 @@
+"""
+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)))
diff --git a/openedx/core/djangoapps/theming/storage.py b/openedx/core/djangoapps/theming/storage.py
index 3fb5311b5a..af565c7c71 100644
--- a/openedx/core/djangoapps/theming/storage.py
+++ b/openedx/core/djangoapps/theming/storage.py
@@ -2,87 +2,304 @@
Comprehensive Theming support for Django's collectstatic functionality.
See https://docs.djangoproject.com/en/1.8/ref/contrib/staticfiles/
"""
-from path import Path
+import posixpath
import os.path
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.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, so that it can generate theme-scoped URLs for themed
- static assets.
+ Comprehensive theme aware Static files storage.
"""
- def __init__(self, *args, **kwargs):
- super(ComprehensiveThemingAwareMixin, self).__init__(*args, **kwargs)
- theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "")
- if not theme_dir:
- self.theme_location = None
- return
+ # prefix for file path, this prefix is added at the beginning of file path before saving static files during
+ # collectstatic command.
+ # e.g. having "edx.org" as prefix will cause files to be saved as "edx.org/images/logo.png"
+ # instead of "images/logo.png"
+ prefix = None
- if not isinstance(theme_dir, basestring):
- raise ImproperlyConfigured("Your COMPREHENSIVE_THEME_DIR setting must be a string")
+ def __init__(self, location=None, base_url=None, file_permissions_mode=None,
+ directory_permissions_mode=None, prefix=None):
- root = Path(settings.PROJECT_ROOT)
- if root.name == "":
- root = root.parent
+ self.prefix = prefix
+ super(ThemeStorage, self).__init__(
+ location=location,
+ base_url=base_url,
+ file_permissions_mode=file_permissions_mode,
+ directory_permissions_mode=directory_permissions_mode,
+ )
- component_dir = Path(theme_dir) / root.name
- self.theme_location = component_dir / "static"
-
- @property
- def prefix(self):
+ def url(self, name):
"""
- This is used by the ComprehensiveThemeFinder in the collection step.
- """
- theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "")
- if not theme_dir:
- return None
- theme_name = os.path.basename(os.path.normpath(theme_dir))
- return "themes/{name}/".format(name=theme_name)
+ Returns url of the asset, themed url will be returned if the asset is themed otherwise default
+ asset url will be returned.
- def themed(self, name):
+ 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'
"""
- Given a name, return a boolean indicating whether that name exists
- as a themed asset in the comprehensive theme.
+ prefix = ''
+ theme = get_current_theme()
+
+ # get theme prefix from site address if if asset is accessed via a url
+ 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)
+
+ return super(ThemeStorage, self).url(name)
+
+ def themed(self, name, theme):
"""
- # Nothing can be themed if we don't have a theme location.
- if not self.theme_location:
+ Returns True if given asset override is provided by the given theme otherwise returns False.
+ Args:
+ name: asset name e.g. 'images/logo.png'
+ theme: theme name e.g. 'red-theme', 'edx.org'
+
+ Returns:
+ True if given asset override is provided by the given theme otherwise returns False
+ """
+ if not is_comprehensive_theming_enabled():
return False
- path = safe_join(self.theme_location, name)
- return os.path.exists(path)
+ # in debug mode check static asset from within the project directory
+ 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
- def path(self, name):
- """
- Get the path to the real asset on disk
- """
- if self.themed(name):
- base = self.theme_location
+ 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:
- base = self.location
- path = safe_join(base, name)
- return os.path.normpath(path)
-
- def url(self, name, *args, **kwargs):
- """
- Add the theme prefix to the asset URL
- """
- if self.themed(name):
- name = self.prefix + name
- return super(ComprehensiveThemingAwareMixin, self).url(name, *args, **kwargs)
+ return self.exists(os.path.join(theme, name))
-class CachedComprehensiveThemingStorage(
- ComprehensiveThemingAwareMixin,
- CachedFilesMixin,
- StaticFilesStorage
-):
+class ThemeCachedFilesMixin(CachedFilesMixin):
"""
- Used by the ComprehensiveThemeFinder class. Mixes in support for cached
- files and comprehensive theming in static files.
+ 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.
+
+ 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.
"""
- pass
+
+ def url(self, name, force=False):
+ """
+ Returns themed url for the given asset.
+ """
+ theme = get_current_theme()
+ if theme and theme.theme_dir_name not in name:
+ # during server run, append theme name to the asset name if it is not already there
+ # this is ensure that correct hash is created and default asset is not always
+ # used to create hash of themed assets.
+ name = os.path.join(theme.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:])
+
+ return super(ThemeCachedFilesMixin, self).url(asset_name, force)
+
+ def url_converter(self, name, template=None):
+ """
+ This is an override of url_converter from CachedFilesMixin.
+ It just comments out two lines at the end of the method.
+
+ The purpose of this override is to make converter method return absolute urls instead of relative urls.
+ This behavior is necessary for theme overrides, as we get 404 on assets with relative urls on a themed site.
+ """
+ if template is None:
+ template = self.default_template
+
+ def converter(matchobj):
+ """
+ Converts the matched URL depending on the parent level (`..`)
+ and returns the normalized and hashed URL using the url method
+ of the storage.
+ """
+ matched, url = matchobj.groups()
+ # Completely ignore http(s) prefixed URLs,
+ # fragments and data-uri URLs
+ if url.startswith(('#', 'http:', 'https:', 'data:', '//')):
+ return matched
+ name_parts = name.split(os.sep)
+ # Using posix normpath here to remove duplicates
+ url = posixpath.normpath(url)
+ url_parts = url.split('/')
+ parent_level, sub_level = url.count('..'), url.count('/')
+ if url.startswith('/'):
+ sub_level -= 1
+ url_parts = url_parts[1:]
+ if parent_level or not url.startswith('/'):
+ start, end = parent_level + 1, parent_level
+ else:
+ if sub_level:
+ if sub_level == 1:
+ parent_level -= 1
+ start, end = parent_level, 1
+ else:
+ start, end = 1, sub_level - 1
+ joined_result = '/'.join(name_parts[:-start] + url_parts[end:])
+ hashed_url = self.url(unquote(joined_result), force=True)
+
+ # NOTE:
+ # following two lines are commented out so that absolute urls are used instead of relative urls
+ # to make themed assets work correctly.
+ #
+ # The lines are commented and not removed to make future django upgrade easier and
+ # show exactly what is changed in this method override
+ #
+ # file_name = hashed_url.split('/')[-1:]
+ # relative_url = '/'.join(url.split('/')[:-1] + file_name)
+
+ # Return the hashed version to the file
+ return template % unquote(hashed_url)
+
+ return converter
+
+
+class ThemePipelineMixin(PipelineMixin):
+ """
+ Mixin to make sure themed assets are also packaged and used along with non themed assets.
+ if a source asset for a particular package is not present then the default asset is used.
+
+ e.g. in the following package and for 'red-theme'
+ 'style-vendor': {
+ 'source_filenames': [
+ 'js/vendor/afontgarde/afontgarde.css',
+ 'css/vendor/font-awesome.css',
+ 'css/vendor/jquery.qtip.min.css',
+ 'css/vendor/responsive-carousel/responsive-carousel.css',
+ 'css/vendor/responsive-carousel/responsive-carousel.slide.css',
+ ],
+ 'output_filename': 'css/lms-style-vendor.css'
+ }
+ 'red-theme/css/vendor/responsive-carousel/responsive-carousel.css' will be used of it exists otherwise
+ 'css/vendor/responsive-carousel/responsive-carousel.css' will be used to create 'red-theme/css/lms-style-vendor.css'
+ """
+ packing = True
+
+ def post_process(self, paths, dry_run=False, **options):
+ """
+ This post_process hook is used to package all themed assets.
+ """
+ if dry_run:
+ 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
diff --git a/openedx/core/djangoapps/theming/template_loaders.py b/openedx/core/djangoapps/theming/template_loaders.py
new file mode 100644
index 0000000000..198388fa8c
--- /dev/null
+++ b/openedx/core/djangoapps/theming/template_loaders.py
@@ -0,0 +1,66 @@
+"""
+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
diff --git a/openedx/core/djangoapps/theming/templatetags/theme_pipeline.py b/openedx/core/djangoapps/theming/templatetags/theme_pipeline.py
new file mode 100644
index 0000000000..7beb99ca55
--- /dev/null
+++ b/openedx/core/djangoapps/theming/templatetags/theme_pipeline.py
@@ -0,0 +1,78 @@
+"""
+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)
diff --git a/openedx/core/djangoapps/theming/tests/test_commands.py b/openedx/core/djangoapps/theming/tests/test_commands.py
new file mode 100644
index 0000000000..163c0de8e0
--- /dev/null
+++ b/openedx/core/djangoapps/theming/tests/test_commands.py
@@ -0,0 +1,53 @@
+"""
+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"])
diff --git a/openedx/core/djangoapps/theming/tests/test_finders.py b/openedx/core/djangoapps/theming/tests/test_finders.py
new file mode 100644
index 0000000000..83497603f1
--- /dev/null
+++ b/openedx/core/djangoapps/theming/tests/test_finders.py
@@ -0,0 +1,55 @@
+"""
+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")
diff --git a/openedx/core/djangoapps/theming/tests/test_helpers.py b/openedx/core/djangoapps/theming/tests/test_helpers.py
index 536e9cc657..091f6fe9a7 100644
--- a/openedx/core/djangoapps/theming/tests/test_helpers.py
+++ b/openedx/core/djangoapps/theming/tests/test_helpers.py
@@ -1,16 +1,45 @@
"""
Test helpers for Comprehensive Theming.
"""
-from django.test import TestCase
+import unittest
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.helpers import get_template_path_with_theme, strip_site_theme_templates_path, \
+ get_themes, Theme, get_theme_base_dir
-class ThemingHelpersTests(TestCase):
- """
- Make sure some of the theming helper functions work
- """
+class TestHelpers(TestCase):
+ """Test comprehensive theming helper functions."""
+
+ def test_get_themes(self):
+ """
+ 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):
"""
@@ -23,3 +52,89 @@ class ThemingHelpersTests(TestCase):
mock_get_value.return_value = {override_key: override_value}
jwt_auth = helpers.get_value('JWT_AUTH')
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')
diff --git a/openedx/core/djangoapps/theming/tests/test_storage.py b/openedx/core/djangoapps/theming/tests/test_storage.py
new file mode 100644
index 0000000000..f0d544743f
--- /dev/null
+++ b/openedx/core/djangoapps/theming/tests/test_storage.py
@@ -0,0 +1,82 @@
+"""
+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)
diff --git a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py
new file mode 100644
index 0000000000..72f81d9d9a
--- /dev/null
+++ b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py
@@ -0,0 +1,120 @@
+"""
+ 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.")
diff --git a/openedx/core/djangoapps/theming/tests/test_util.py b/openedx/core/djangoapps/theming/tests/test_util.py
index 6f13b9c187..8ead84bd62 100644
--- a/openedx/core/djangoapps/theming/tests/test_util.py
+++ b/openedx/core/djangoapps/theming/tests/test_util.py
@@ -6,87 +6,63 @@ from functools import wraps
import os
import os.path
import contextlib
+import re
from mock import patch
from django.conf import settings
-from django.template import Engine
-from django.test.utils import override_settings
+from django.contrib.sites.models import Site
import edxmako
-
-from openedx.core.djangoapps.theming.core import comprehensive_theme_changes
-
-EDX_THEME_DIR = settings.REPO_ROOT / "themes" / "edx.org"
+from openedx.core.djangoapps.theming.models import SiteTheme
-def with_comprehensive_theme(theme_dir):
+def with_comprehensive_theme(theme_dir_name):
"""
- A decorator to run a test with a particular comprehensive theme.
-
+ A decorator to run a test with a comprehensive theming enabled.
Arguments:
- theme_dir (str): the full path to the theme directory to use.
- This will likely use `settings.REPO_ROOT` to get the full path.
-
+ theme_dir_name (str): directory name of the site for which we want comprehensive theming enabled.
"""
- # This decorator gets the settings changes needed for a theme, and applies
- # them using the override_settings and edxmako.paths.add_lookup context
- # managers.
-
- changes = comprehensive_theme_changes(theme_dir)
-
+ # This decorator creates Site and SiteTheme models for given domain
def _decorator(func): # pylint: disable=missing-docstring
@wraps(func)
def _decorated(*args, **kwargs): # pylint: disable=missing-docstring
- with override_settings(COMPREHENSIVE_THEME_DIR=theme_dir, **changes['settings']):
- default_engine = Engine.get_default()
- dirs = default_engine.dirs[:]
- with edxmako.save_lookups():
- for template_dir in changes['template_paths']:
- edxmako.paths.add_lookup('main', template_dir, prepend=True)
- dirs.insert(0, template_dir)
- with patch.object(default_engine, 'dirs', dirs):
- return func(*args, **kwargs)
+ # make a domain name out of directory name
+ domain = "{theme_dir_name}.org".format(theme_dir_name=re.sub(r"\.org$", "", theme_dir_name))
+ site, __ = Site.objects.get_or_create(domain=domain, name=domain)
+ site_theme, __ = SiteTheme.objects.get_or_create(site=site, theme_dir_name=theme_dir_name)
+
+ 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):
+ return func(*args, **kwargs)
return _decorated
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
-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:
- 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:
- changes = comprehensive_theme_changes(EDX_THEME_DIR)
- with override_settings(COMPREHENSIVE_THEME_DIR=EDX_THEME_DIR, **changes['settings']):
- with edxmako.save_lookups():
- for template_dir in changes['template_paths']:
- edxmako.paths.add_lookup('main', template_dir, prepend=True)
+ if theme:
+ domain = '{theme}.org'.format(theme=re.sub(r"\.org$", "", theme))
+ site, __ = Site.objects.get_or_create(domain=domain, name=theme)
+ site_theme, __ = SiteTheme.objects.get_or_create(site=site, theme_dir_name=theme)
+ 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
else:
yield
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
index 76dab0b94e..55025e357f 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
@@ -248,7 +248,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=self.test_password)
self.create_mock_profile(self.user)
- with self.assertNumQueries(17):
+ with self.assertNumQueries(18):
response = self.send_get(self.different_client)
self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY)
@@ -263,7 +263,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=self.test_password)
self.create_mock_profile(self.user)
- with self.assertNumQueries(17):
+ with self.assertNumQueries(18):
response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
@@ -337,12 +337,12 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase):
self.assertEqual(False, data["accomplishments_shared"])
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
self.user.is_active = False
self.user.save()
- verify_get_own_information(10)
+ verify_get_own_information(11)
def test_get_account_empty_string(self):
"""
@@ -356,7 +356,7 @@ class TestAccountAPI(CacheIsolationTestCase, UserAPITestCase):
legacy_profile.save()
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)
for empty_field in ("level_of_education", "gender", "country", "bio"):
self.assertIsNone(response.data[empty_field])
diff --git a/openedx/core/lib/tempdir.py b/openedx/core/lib/tempdir.py
index 8d440ad14c..4932f52956 100644
--- a/openedx/core/lib/tempdir.py
+++ b/openedx/core/lib/tempdir.py
@@ -17,3 +17,22 @@ def cleanup_tempdir(the_dir):
"""Called on process exit to remove a temp directory."""
if os.path.exists(the_dir):
shutil.rmtree(the_dir)
+
+
+def create_symlink(src, dest):
+ """
+ Creates a symbolic link which will be deleted when the process ends.
+ :param src: path to source
+ :param dest: path to destination
+ """
+ os.symlink(src, dest)
+ atexit.register(delete_symlink, dest)
+
+
+def delete_symlink(link_path):
+ """
+ Removes symbolic link for
+ :param link_path:
+ """
+ if os.path.exists(link_path):
+ os.remove(link_path)
diff --git a/openedx/core/storage.py b/openedx/core/storage.py
index 93139e3fa4..225e4b7e61 100644
--- a/openedx/core/storage.py
+++ b/openedx/core/storage.py
@@ -2,18 +2,22 @@
Django storage backends for Open edX.
"""
from django_pipeline_forgiving.storages import PipelineForgivingStorage
-from django.contrib.staticfiles.storage import StaticFilesStorage, CachedFilesMixin
-from pipeline.storage import PipelineMixin, NonPackagingMixin
+from django.contrib.staticfiles.storage import StaticFilesStorage
+from pipeline.storage import NonPackagingMixin
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(
PipelineForgivingStorage,
- ComprehensiveThemingAwareMixin,
OptimizedFilesMixin,
- PipelineMixin,
- CachedFilesMixin,
+ ThemePipelineMixin,
+ ThemeCachedFilesMixin,
+ ThemeStorage,
StaticFilesStorage
):
"""
@@ -24,9 +28,9 @@ class ProductionStorage(
class DevelopmentStorage(
- ComprehensiveThemingAwareMixin,
NonPackagingMixin,
- PipelineMixin,
+ ThemePipelineMixin,
+ ThemeStorage,
StaticFilesStorage
):
"""
diff --git a/pavelib/assets.py b/pavelib/assets.py
index c374537a7a..269b6b0a9a 100644
--- a/pavelib/assets.py
+++ b/pavelib/assets.py
@@ -18,28 +18,28 @@ from watchdog.events import PatternMatchingEventHandler
from .utils.envs import Env
from .utils.cmd import cmd, django_cmd
+from openedx.core.djangoapps.theming.paver_helpers import get_theme_paths
+
# setup baseline paths
ALL_SYSTEMS = ['lms', 'studio']
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"),
-]
-LMS_SASS_DIRECTORIES = [
- 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',
+ path('node_modules'),
+ path('node_modules/edx-pattern-library/node_modules'),
]
# A list of NPM installed libraries that should be copied into the common
@@ -58,60 +58,197 @@ NPM_INSTALLED_LIBRARIES = [
# Directory to install static vendor files
NPM_VENDOR_DIRECTORY = path("common/static/common/js/vendor")
-
-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()
+# 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 applicable_sass_directories(systems=None):
+def get_sass_directories(system, theme_dir=None):
"""
- Determine the applicable set of SASS directories to be
- compiled for the specified list of systems.
+ Determine the set of SASS directories to be compiled for the specified list of system and theme
+ and return a list of those directories.
- Args:
- systems: A list of systems (defaults to all)
+ 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.
+ }
- Returns:
- A list of SASS directories to be compiled.
+ 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 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)
+ 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,
+ })
+
+ 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):
"""
Prevents the decorated function from being called more than every `seconds`
@@ -169,11 +306,15 @@ class SassWatcher(PatternMatchingEventHandler):
patterns = ['*.scss']
ignore_patterns = ['common/static/xmodule/*']
- def register(self, observer):
+ def register(self, observer, directories):
"""
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 = []
if '*' in dirname:
paths.extend(glob.glob(dirname))
@@ -257,12 +398,133 @@ def compile_coffeescript(*files):
@no_help
@cmdopts([
('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'),
('force', '', 'Force full compilation'),
])
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.
@@ -270,12 +532,14 @@ def compile_sass(options):
# installed. In particular, this allows the install_prereqs command to be
# used to install the dependency.
import sass
+ if system == "common":
+ sass_dirs = get_common_sass_directories()
+ else:
+ sass_dirs = get_sass_directories(system, theme)
- debug = options.get('debug')
- force = options.get('force')
- systems = getattr(options, 'system', ALL_SYSTEMS)
- if isinstance(systems, basestring):
- systems = systems.split(',')
+ dry_run = tasks.environment.dry_run
+
+ # determine css out put style and source comments enabling
if debug:
source_comments = True
output_style = 'nested'
@@ -283,13 +547,18 @@ def compile_sass(options):
source_comments = False
output_style = 'compressed'
- timing_info = []
- 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:
+ for dirs in sass_dirs:
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 dry_run:
@@ -301,22 +570,18 @@ def compile_sass(options):
if dry_run:
tasks.environment.info("libsass {sass_dir}".format(
- sass_dir=sass_dir,
+ sass_dir=sass_source_dir,
))
else:
sass.compile(
- dirname=(sass_dir, css_dir),
- include_paths=SASS_LOAD_PATHS + all_sass_directories,
+ dirname=(sass_source_dir, css_dir),
+ include_paths=COMMON_LOOKUP_PATHS + lookup_paths,
source_comments=source_comments,
output_style=output_style,
)
duration = datetime.now() - start
- timing_info.append((sass_dir, css_dir, duration))
-
- 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))
+ timing_info.append((sass_source_dir, css_dir, duration))
+ return True
def compile_templated_sass(systems, settings):
@@ -387,8 +652,36 @@ def collect_assets(systems, settings):
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
-@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):
"""
Watch for changes to asset files, and regenerate js/css
@@ -397,11 +690,26 @@ def watch_assets(options):
if tasks.environment.dry_run:
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()
CoffeeScriptWatcher().register(observer)
- SassWatcher().register(observer)
- XModuleSassWatcher().register(observer)
+ SassWatcher().register(observer, sass_directories)
+ XModuleSassWatcher().register(observer, ['common/lib/xmodule/'])
XModuleAssetsWatcher().register(observer)
print("Starting asset watcher...")
@@ -447,16 +755,29 @@ def update_assets(args):
'--watch', action='store_true', default=False,
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)
compile_templated_sass(args.system, args.settings)
process_xmodule_assets()
process_npm_assets()
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:
collect_assets(args.system, args.settings)
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},
+ )
diff --git a/pavelib/paver_tests/test_assets.py b/pavelib/paver_tests/test_assets.py
index b1b94e732b..37f105993a 100644
--- a/pavelib/paver_tests/test_assets.py
+++ b/pavelib/paver_tests/test_assets.py
@@ -1,12 +1,16 @@
"""Unit tests for the Paver asset tasks."""
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 watchdog.observers.polling import PollingObserver
-
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
class TestPaverAssetTasks(PaverTestCase):
@@ -43,13 +47,11 @@ class TestPaverAssetTasks(PaverTestCase):
if force:
expected_messages.append("rm -rf common/static/css/*.css")
expected_messages.append("libsass common/static/sass")
+
if "lms" in system:
if force:
expected_messages.append("rm -rf lms/static/css/*.css")
expected_messages.append("libsass lms/static/sass")
- if force:
- expected_messages.append("rm -rf lms/static/css/*.css")
- expected_messages.append("libsass lms/static/themed_sass")
if force:
expected_messages.append("rm -rf lms/static/certificates/css/*.css")
expected_messages.append("libsass lms/static/certificates/sass")
@@ -57,4 +59,146 @@ class TestPaverAssetTasks(PaverTestCase):
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("libsass lms/static/sass")
+ if force:
+ expected_messages.append("rm -rf lms/static/certificates/css/*.css")
+ expected_messages.append("libsass lms/static/certificates/sass")
+
+ 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:
+ expected_messages.append("rm -rf cms/static/css/*.css")
+ expected_messages.append("libsass cms/static/sass")
+
+ 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)
diff --git a/pavelib/paver_tests/test_servers.py b/pavelib/paver_tests/test_servers.py
index be50696bf9..364c01c119 100644
--- a/pavelib/paver_tests/test_servers.py
+++ b/pavelib/paver_tests/test_servers.py
@@ -17,12 +17,17 @@ EXPECTED_COMMON_SASS_DIRECTORIES = [
]
EXPECTED_LMS_SASS_DIRECTORIES = [
u"lms/static/sass",
- u"lms/static/themed_sass",
u"lms/static/certificates/sass",
]
EXPECTED_CMS_SASS_DIRECTORIES = [
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 = (
u"python manage.py {system} --settings={asset_settings} preprocess_assets"
u" {system}/static/sass/*.scss {system}/static/themed_sass"
@@ -234,7 +239,7 @@ class TestPaverServerTasks(PaverTestCase):
expected_messages.append(u"xmodule_assets common/static/xmodule")
expected_messages.append(u"install npm_assets")
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:
expected_messages.append(EXPECTED_COLLECT_STATIC_COMMAND.format(
system=system, asset_settings=expected_asset_settings
@@ -276,7 +281,7 @@ class TestPaverServerTasks(PaverTestCase):
expected_messages.append(u"xmodule_assets common/static/xmodule")
expected_messages.append(u"install npm_assets")
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:
expected_messages.append(EXPECTED_COLLECT_STATIC_COMMAND.format(
system="lms", asset_settings=expected_asset_settings
@@ -301,14 +306,13 @@ class TestPaverServerTasks(PaverTestCase):
expected_messages.append(EXPECTED_CELERY_COMMAND.format(settings="dev_with_worker"))
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.
"""
- expected_sass_directories = []
- expected_sass_directories.extend(EXPECTED_COMMON_SASS_DIRECTORIES)
+ expected_sass_commands = []
if system != 'cms':
- expected_sass_directories.extend(EXPECTED_LMS_SASS_DIRECTORIES)
+ expected_sass_commands.extend(EXPECTED_LMS_SASS_COMMAND)
if system != 'lms':
- expected_sass_directories.extend(EXPECTED_CMS_SASS_DIRECTORIES)
- return [EXPECTED_SASS_COMMAND.format(sass_directory=directory) for directory in expected_sass_directories]
+ expected_sass_commands.extend(EXPECTED_CMS_SASS_COMMAND)
+ return [command.format(asset_settings=asset_settings) for command in expected_sass_commands]
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index bf8dca3556..c22b26655d 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -45,7 +45,7 @@
# Third-party:
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/MongoDBProxy.git@25b99097615bda06bd7cdfe5669ed80dc2a7fed0#egg=MongoDBProxy==0.1.0
git+https://github.com/edx/nltk.git@2.0.6#egg=nltk==2.0.6
diff --git a/themes/README.rst b/themes/README.rst
index 90f42b6215..162363e67f 100644
--- a/themes/README.rst
+++ b/themes/README.rst
@@ -133,9 +133,9 @@ directory. There are two ways to do this.
$ sudo /edx/bin/update edx-platform HEAD
#. 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.
diff --git a/themes/red-theme/cms/templates/login.html b/themes/red-theme/cms/templates/login.html
new file mode 100644
index 0000000000..8f9274f7a4
--- /dev/null
+++ b/themes/red-theme/cms/templates/login.html
@@ -0,0 +1,57 @@
+<%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">
+
+%block>
+
+<%block name="requirejs">
+ require(["js/factories/login"], function(LoginFactory) {
+ LoginFactory("${reverse('homepage')}");
+ });
+%block>
diff --git a/themes/red-theme/lms/static/sass/_overrides.scss b/themes/red-theme/lms/static/sass/_overrides.scss
deleted file mode 100755
index 4e5e1f2b6e..0000000000
--- a/themes/red-theme/lms/static/sass/_overrides.scss
+++ /dev/null
@@ -1,7 +0,0 @@
-// Theming overrides for sample theme
-$header-bg: rgb(250,0,0);
-$footer-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';
diff --git a/themes/red-theme/lms/static/sass/lms-main-rtl.scss b/themes/red-theme/lms/static/sass/lms-main-rtl.scss
deleted file mode 100755
index 3eaad226a2..0000000000
--- a/themes/red-theme/lms/static/sass/lms-main-rtl.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-// Theming overrides for sample theme
-@import 'overrides';
-
-// import the rest of the application
-@import 'lms/static/sass/lms-main-rtl';
diff --git a/themes/red-theme/lms/static/sass/lms-main.scss b/themes/red-theme/lms/static/sass/lms-main.scss
deleted file mode 100755
index d6287e8215..0000000000
--- a/themes/red-theme/lms/static/sass/lms-main.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-// Theming overrides for sample theme
-@import 'overrides';
-
-// import the rest of the application
-@import 'lms/static/sass/lms-main';
diff --git a/themes/red-theme/lms/static/sass/partials/base/_variables.scss b/themes/red-theme/lms/static/sass/partials/base/_variables.scss
new file mode 100755
index 0000000000..c869ff9856
--- /dev/null
+++ b/themes/red-theme/lms/static/sass/partials/base/_variables.scss
@@ -0,0 +1,5 @@
+@import 'lms/static/sass/partials/base/variables';
+
+$header-bg: rgb(250,0,0);
+$footer-bg: rgb(250,0,0);
+$container-bg: rgb(250,0,0);
diff --git a/themes/red-theme/lms/templates/footer.html b/themes/red-theme/lms/templates/footer.html
index 69978fb3ca..3570d92901 100755
--- a/themes/red-theme/lms/templates/footer.html
+++ b/themes/red-theme/lms/templates/footer.html
@@ -72,7 +72,8 @@ from django.utils.translation import ugettext as _
honor_link = u"".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_end="",
honor_link_start=honor_link,
diff --git a/themes/red-theme/lms/templates/header.html b/themes/red-theme/lms/templates/header.html
index 864571cc56..050ed074ab 100755
--- a/themes/red-theme/lms/templates/header.html
+++ b/themes/red-theme/lms/templates/header.html
@@ -4,6 +4,7 @@
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
+from openedx.core.djangolib.markup import HTML
# App that handles subdomain specific branding
import branding
@@ -36,7 +37,7 @@ site_status_msg = get_site_status_msg(course_id)
% endif
%block>
-