diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 811bd3b6891eaf31f96d32d358ff4ce3a8ac1467..090fed25aaeecc0703bfdbc72cd6659ef42e2707 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 15bd0dee05969281b9a2865331d3edffbff99b84..da12fb1b29eb96fcd35d1a94ad99922a235b573d 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 0d210366d365a52205e8a9fe63601ee71222e352..3bbee70addbcc7287044ba75bfbf1ac17a18dcd6 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 044a997ceac712eb6dc294a827a3341d37cb89d0..966f09b2672882aa47a5fe7941443dd8bd2570c5 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 dd11b2171552986609b7219b63334d5ef2de7c91..d5796406be1a842dca6673f95bed5656e5d2482f 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 aa688eff21a4ae624c6799f44115ba0f847de646..05ca7a21433bf27c41721a35966bfc9c80cb578f 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 4fc4dfce358632cd7079b2bca6c919b5fc8115a8..f51b3c1325c43be271df5d0d56ed05e7e6002b34 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 8fa21e0fdeb44040ab398c4e0744892e056cb00f..7ca8f6866b714369266657138e7fb9ab550b1eed 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 ba0d70b9941133839931ce7157303a5a4a145984..8a21a1d0f164154df9cf9a747ea08d479175fb81 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 3e7bb40430e22f86f5ddfdcfb4b7063d6cf2c0ce..ed41818f538bdad2c4691ea07c6c8a66d1141567 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 94cb1cdd014b3d240d974a9ccbae1ddc363242e3..f1e32138d564246d1af3c8362107a387e47bdb00 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 a8fec36caada47f2fb452411a499dfa9af3d87d8..43743ee93581f2fbcf16a866f70d820639ed06a0 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 43a96cf19d4728ea0853da383aac37276cd8722c..d643dfe695e2b96f6709927352025b99212a024d 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 1cef0015945c4df55f0d1c6c2019773147dc793a..4ed13753cb150564ff2a94c4ab736472a6ed731b 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 430b4e503f70d2026f625d4e846df2eefddc7e48..2dbd30625e0979ec41af6063f8a92db7e88f53d6 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 018aad8dd8b4c7c3dc4f856ac8d59705d1ee9bc0..ba19c3706204ad670ca60f2018d69def74a96ef9 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 0000000000000000000000000000000000000000..5a7e8bc11b65583b2d128ee5ecd54d6710d38bbc --- /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 0000000000000000000000000000000000000000..b3a52671178584be50fa788ca8c2e4c8f2ab7fb8 --- /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 0000000000000000000000000000000000000000..4265a16fec0a1d65fb6d05b1e8f11557b5c3f64d --- /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 0000000000000000000000000000000000000000..a5faf86f1cff480e39edf842cf24acef1d892a0f --- /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"> +<div class="wrapper-content wrapper"> + <section class="content"> + <header> + <h1 class="title title-1">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</h1> + <a href="${reverse('signup')}" class="action action-signin">${_("Don't have a {studio_name} Account? Sign up!").format(studio_name=settings.STUDIO_SHORT_NAME)}</a> + </header> + <!-- Login Page override for test-theme. --> + <article class="content-primary" role="main"> + <form id="login_form" method="post" action="login_post" onsubmit="return false;"> + + <fieldset> + <legend class="sr">${_("Required Information to Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</legend> + <input type="hidden" name="csrfmiddlewaretoken" value="${ csrf }" /> + + <ol class="list-input"> + <li class="field text required" id="field-email"> + <label for="email">${_("E-mail")}</label> + <input id="email" type="email" name="email" placeholder="${_('example: username@domain.com')}"/> + </li> + + <li class="field text required" id="field-password"> + <label for="password">${_("Password")}</label> + <input id="password" type="password" name="password" /> + <a href="${forgot_password_link}" class="action action-forgotpassword">${_("Forgot password?")}</a> + </li> + </ol> + </fieldset> + + <div class="form-actions"> + <button type="submit" id="submit" name="submit" class="action action-primary">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</button> + </div> + + <!-- no honor code for CMS, but need it because we're using the lms student object --> + <input name="honor_code" type="checkbox" value="true" checked="true" hidden="true"> + </form> + </article> + </section> +</div> +</%block> + +<%block name="requirejs"> + require(["js/factories/login"], function(LoginFactory) { + LoginFactory("${reverse('homepage') | n, js_escaped_string }"); + }); +</%block> 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 0000000000000000000000000000000000000000..b3a52671178584be50fa788ca8c2e4c8f2ab7fb8 --- /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 0000000000000000000000000000000000000000..5efc6b63a436b749addf0c7a978f9cb78ba03eab 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 0000000000000000000000000000000000000000..43f66799a0f78ca4a33b42665e9405940a7d3893 --- /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 0000000000000000000000000000000000000000..6f12ccfd55c2c4e73afdc7ab9e65de8310b4dad0 --- /dev/null +++ b/common/test/test-theme/lms/templates/footer.html @@ -0,0 +1,12 @@ +<%page expression_filter="h"/> + +<div class="wrapper wrapper-footer"> + <footer> + <div class="colophon"> + <div class="colophon-about"> + <p>This is a footer for test-theme.</p> + </div> + </div> + + </footer> +</div> diff --git a/lms/djangoapps/branding/tests/test_views.py b/lms/djangoapps/branding/tests/test_views.py index eb62bf2282ad8ee989977821daa9a39f9942d337..991f528809b8582c2d5f1625ce1dac64ce61f419 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 b28fd7a84b0ce534ec8933284bce117228736fb3..50ab358d89d327d1fc8753f22d8a4375669fa956 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 e32af4991f5d0071b575bab44398a6584b09e0ad..375415b252da151b6f113908058aa031eafcd9b0 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 f1681a4e7fbfffe2be5ca715c56cc1cec5405c19..c930ef8fd80567d0658a65a42b94ff6f47218894 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 ee28177a15646b8eafe331135523a644a46af9c2..e6702ab9eacffb4065f910d6729452b9bf66e936 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("<footer>TEMPORARY THEME</footer>") + dest_path = path(settings.COMPREHENSIVE_THEME_DIRS[0]) / tmp_theme + create_symlink(themes_dir / tmp_theme, dest_path) + @with_comprehensive_theme(tmp_theme) 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 b02dd525384a237de532c952d944a052d5bf957b..ccb7ebacca5dccb21ca09436a9ce35fda696fa74 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 44cc93cbdd140ff987cb7d4d52796b8fe6646ba3..1f8c2aa00e2a76a75c69bb8da14413beb41b9607 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 258e20b3b6cace23cd82a0827c6f472f94a3b5d0..04254c8da00a3cdebded2ae66fb4463959beb6c4 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 bbce7dc065b517f1c125f8a9d2225935d683b726..03d823c8e4298ac1a5b68171ac6ab363570b48e8 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 c41063001012e738c8ef7464a52aa789fd46ea44..b47b02de0c93c6a6db2d88ed5fc94e208a6bc057 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 d5231e2d48b08cfc4d279a5b9d1d1e1a2f16cf4b..2044fab825f2b0c1f6dc3b5f1d1620081d5bf0ba 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 840daaad0fadeb89ea9e734ce483bcdc9500dd6a..29d264576a352f6a756ad350b9699c78a9ad5630 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 70e69196a414474376c0e76035426e7ebfd9406f..047bf8d27b33c39213f44309fd2b77f8e706dbbf 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 4913dcb83ac4fdfa5e6537020d6bfc83fb2ce7d5..b8be3cea7b2d2dd738a28af284142f839f69e565 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 259d4e8a0446e136764c12c325b2c18af00f739e..13c5007dbff046b632c98e18f602cc47499cb47a 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 ad8ddf00bd0e83008d4bff0680cb824638179c85..cea9b31b4919fdc4f4fe44a28d55fb3a8124e2ad 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 d000668a1233ea3dbc8c71393148fb58df76b98a..cdff03bfeadb4c54e975a6d8e90bcafef37035ad 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 b6e63c32320abee55044be288378fc9d555eeaf2..a88b786c9dda8e1e2f3da53ca3b6b081099273e5 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 d57c796f058b9dabac6b6aa84afaf8d29c3bd9b4..f04d7faf404ac130b640bf15901ed23269cf4d3f 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 b2dd8e66721996c668b3fcc05c137ac10933af8a..0d24d3c8790710161da8be4a0c1ab9818c67c2c5 100644 --- a/lms/templates/main_django.html +++ b/lms/templates/main_django.html @@ -1,5 +1,5 @@ <!DOCTYPE html> -{% load sekizai_tags i18n microsite pipeline optional_include staticfiles %} +{% load sekizai_tags i18n microsite theme_pipeline optional_include staticfiles %} {% load url from future %} <html lang="{{LANGUAGE_CODE}}"> <head> diff --git a/lms/templates/wiki/base.html b/lms/templates/wiki/base.html index f19e6c20b8ddd910b3f4ed46a19449090f9800d4..78d3cf4a923932f249b45ec9635a9f0741db58d2 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 75f57e96038a98732c1e14aa2497f3299d834e85..a2e44b3526cb1c05122cb55deece3b17bcc0eb44 100644 --- a/lms/templates/wiki/preview_inline.html +++ b/lms/templates/wiki/preview_inline.html @@ -1,5 +1,5 @@ <!DOCTYPE html> -{% load wiki_tags i18n %}{% load pipeline %} +{% load wiki_tags i18n %}{% load theme_pipeline %} <html lang="{{LANGUAGE_CODE}}"> <head> {% stylesheet 'course' %} diff --git a/openedx/core/djangoapps/bookmarks/tests/test_views.py b/openedx/core/djangoapps/bookmarks/tests/test_views.py index 6945bce77cbc1f81ae238dd5ea8726510b8dfc30..76acc66be35948132de1d68a1bdd03a3381beff2 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 0000000000000000000000000000000000000000..690016f8c8c8d2adec43b979fff79334baa023c7 --- /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 9203cc5c1f4b900b88057409f9471de892a0ca20..27865f2efc8570c274a79c53a76db0ec6368351c 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): - """ - 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: +from logging import getLogger +logger = getLogger(__name__) # pylint: disable=invalid-name - * 'settings': a dictionary of settings names and their new values. - - * 'template_paths': a list of directories to prepend to template - lookup path. +def enable_theming(): """ - - changes = { - 'settings': {}, - 'template_paths': [], - } - root = Path(settings.PROJECT_ROOT) - if root.name == "": - root = root.parent - - component_dir = theme_dir / root.name - - templates_dir = component_dir / "templates" - if templates_dir.isdir(): - changes['template_paths'].append(templates_dir) - - staticfiles_dir = component_dir / "static" - if staticfiles_dir.isdir(): - changes['settings']['STATICFILES_DIRS'] = [staticfiles_dir] + settings.STATICFILES_DIRS - - locale_dir = component_dir / "conf" / "locale" - if locale_dir.isdir(): - changes['settings']['LOCALE_PATHS'] = [locale_dir] + settings.LOCALE_PATHS - - return changes - - -def enable_comprehensive_theme(theme_dir): - """ - Add directories to relevant paths for comprehensive theming. + Add directories and relevant paths to settings 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) + # 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" + ) + + for theme in get_themes(): + locale_dir = theme.path / "conf" / "locale" + if locale_dir.isdir(): + settings.LOCALE_PATHS = (locale_dir, ) + settings.LOCALE_PATHS + + if theme.themes_base_dir not in settings.MAKO_TEMPLATES['main']: + settings.MAKO_TEMPLATES['main'].insert(0, theme.themes_base_dir) diff --git a/openedx/core/djangoapps/theming/finders.py b/openedx/core/djangoapps/theming/finders.py index cbf4366f5a6f1e2fbe8613a3a612765e8cdeedbb..9bf300af31e5a2eedaff6a56cf002282dfe2aa55 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 + super(ThemeFilesFinder, self).__init__(*args, **kwargs) - component_dir = Path(theme_dir) / root.name - static_dir = component_dir / "static" - self.storage = CachedComprehensiveThemingStorage(location=static_dir) + def list(self, ignore_patterns): + """ + List all files in all app storages. + """ + for storage in six.itervalues(self.storages): + if storage.exists(''): # check if storage location exists + for path in utils.get_files(storage, ignore_patterns): + yield path, storage def find(self, path, all=False): # pylint: disable=redefined-builtin """ - Looks for files in the default file storage, if it's local. + Looks for files in the theme directories. """ - if not self.storage: - return [] - - if path.startswith(self.storage.prefix): - # strip the prefix - path = path[len(self.storage.prefix):] + matches = [] + theme_dir_name = path.split("/", 1)[0] - if self.storage.exists(path): - match = self.storage.path(path) - if all: - match = [match] - return match + 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 - return [] - - def list(self, ignore_patterns): + def find_in_theme(self, theme, path): """ - List all files of the storage. + Find a requested static file in an theme's static locations. """ - if self.storage and self.storage.exists(''): - for path in utils.get_files(self.storage, ignore_patterns): - yield path, self.storage + storage = self.storages.get(theme, None) + if storage: + # only try to find a file if the source dir actually exists + if storage.exists(path): + matched_path = storage.path(path) + if matched_path: + return matched_path diff --git a/openedx/core/djangoapps/theming/helpers.py b/openedx/core/djangoapps/theming/helpers.py index 82434934cb32f300bff1e48c58ff359e6c6106f6..24eee891a064eb3a6c29a0998c38b4c8d25c76b2 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"<Theme: {name} at '{path}'>".format(name=self.name, path=self.path) + + def __repr__(self): + return self.__unicode__() + + @property + def path(self): + """ + Get absolute path of the directory that contains current theme's templates, static assets etc. + + Returns: + Path: absolute path to current theme's contents + """ + return Path(self.themes_base_dir) / self.theme_dir_name / get_project_root_name() + + @property + def template_path(self): + """ + Get absolute path of current theme's template directory. + + Returns: + Path: absolute path to current theme's template directory + """ + return Path(self.theme_dir_name) / get_project_root_name() / 'templates' + + @property + def template_dirs(self): + """ + Get a list of all template directories for current theme. + + Returns: + list: list of all template directories for current theme. + """ + return [ + self.path / 'templates', + ] diff --git a/openedx/core/djangoapps/theming/management/__init__.py b/openedx/core/djangoapps/theming/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..61f0a0f54c406409811c9d1fe2bce1fee1d606cf --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 0000000000000000000000000000000000000000..8202f5d2c38e3b6e56a5de74d1ff0f13c1ac06f8 --- /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 0000000000000000000000000000000000000000..614a355b7d12d041f5b4dabc47a9c45052c9b518 --- /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 0000000000000000000000000000000000000000..ebf80f9d3e06ad6da33726ab2ab6deecf6221387 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openedx/core/djangoapps/theming/models.py b/openedx/core/djangoapps/theming/models.py new file mode 100644 index 0000000000000000000000000000000000000000..d6e2e43b10446f3330bae0d3a9b5a9c9361513bc --- /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 0000000000000000000000000000000000000000..15eae7236898d47368e261aeae90eb9f7d4f9fcc --- /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 3fb5311b5a6b4f591d76167a5578380eec7245bf..af565c7c71ca204718463fc339d333eaea1c894a 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 - - if not isinstance(theme_dir, basestring): - raise ImproperlyConfigured("Your COMPREHENSIVE_THEME_DIR setting must be a string") + # 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 - root = Path(settings.PROJECT_ROOT) - if root.name == "": - root = root.parent + def __init__(self, location=None, base_url=None, file_permissions_mode=None, + directory_permissions_mode=None, prefix=None): - component_dir = Path(theme_dir) / root.name - self.theme_location = component_dir / "static" + 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, + ) - @property - def prefix(self): + def url(self, name): """ - This is used by the ComprehensiveThemeFinder in the collection step. + Returns url of the asset, themed url will be returned if the asset is themed otherwise default + asset url will be returned. + + Args: + name: name of the asset, e.g. 'images/logo.png' + + Returns: + url of the asset, e.g. '/static/red-theme/images/logo.png' if current theme is red-theme and logo + is provided by red-theme otherwise '/static/images/logo.png' """ - theme_dir = getattr(settings, "COMPREHENSIVE_THEME_DIR", "") - if not theme_dir: - return None - theme_name = os.path.basename(os.path.normpath(theme_dir)) - return "themes/{name}/".format(name=theme_name) + 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) - def themed(self, name): + return super(ThemeStorage, self).url(name) + + def themed(self, name, theme): """ - Given a name, return a boolean indicating whether that name exists - as a themed asset in the comprehensive theme. + Returns True if given asset override is provided by the given theme otherwise returns False. + Args: + name: asset name e.g. 'images/logo.png' + theme: theme name e.g. 'red-theme', 'edx.org' + + Returns: + True if given asset override is provided by the given theme otherwise returns False """ - # Nothing can be themed if we don't have a theme location. - if not self.theme_location: + 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 + + themed_path = "/".join([ + themes_location, + theme, + get_project_root_name(), + "static/" + ]) + name = name[1:] if name.startswith("/") else name + path = safe_join(themed_path, name) + return os.path.exists(path) + # in live mode check static asset in the static files dir defined by "STATIC_ROOT" setting + else: + return self.exists(os.path.join(theme, name)) + + +class ThemeCachedFilesMixin(CachedFilesMixin): + """ + Comprehensive theme aware CachedFilesMixin. + Main purpose of subclassing CachedFilesMixin is to override the following methods. + 1 - url + 2 - url_converter + + url: + This method takes asset name as argument and is responsible for adding hash to the name to support caching. + This method is called during both collectstatic command and live server run. + + When called during collectstatic command that name argument will be asset name inside STATIC_ROOT, + for non themed assets it will be the usual path (e.g. 'images/logo.png') but for themed asset it will + also contain themes dir prefix (e.g. 'red-theme/images/logo.png'). So, here we check whether the themed asset + exists or not, if it exists we pass the same name up in the MRO chain for further processing and if it does not + exists we strip theme name and pass the new asset name to the MRO chain for further processing. + + When called during server run, we get the theme dir for the current site using `get_current_theme` and + make sure to prefix theme dir to the asset name. This is done to ensure the usage of correct hash in file name. + e.g. if our red-theme overrides 'images/logo.png' and we do not prefix theme dir to the asset name, the hash for + '{platform-dir}/lms/static/images/logo.png' would be used instead of + '{themes_base_dir}/red-theme/images/logo.png' + + url_converter: + This function returns another function that is responsible for hashing urls that appear inside assets + (e.g. url("images/logo.png") inside css). The method defined in the superclass adds a hash to file and returns + relative url of the file. + e.g. for url("../images/logo.png") it would return url("../images/logo.790c9a5340cb.png"). However we would + want it to return absolute url (e.g. url("/static/images/logo.790c9a5340cb.png")) so that it works properly + with themes. - def path(self, name): + The overridden method here simply comments out the two lines that convert absolute url to relative url, + hence absolute urls are used instead of relative urls. + """ + + def url(self, name, force=False): """ - Get the path to the real asset on disk + Returns themed url for the given asset. """ - if self.themed(name): - base = self.theme_location - else: - base = self.location - path = safe_join(base, name) - return os.path.normpath(path) + 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:]) - def url(self, name, *args, **kwargs): + return super(ThemeCachedFilesMixin, self).url(asset_name, force) + + def url_converter(self, name, template=None): """ - Add the theme prefix to the asset URL + This is an override of url_converter from CachedFilesMixin. + It just comments out two lines at the end of the method. + + The purpose of this override is to make converter method return absolute urls instead of relative urls. + This behavior is necessary for theme overrides, as we get 404 on assets with relative urls on a themed site. """ - if self.themed(name): - name = self.prefix + name - return super(ComprehensiveThemingAwareMixin, self).url(name, *args, **kwargs) + 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) -class CachedComprehensiveThemingStorage( - ComprehensiveThemingAwareMixin, - CachedFilesMixin, - StaticFilesStorage -): + # Return the hashed version to the file + return template % unquote(hashed_url) + + return converter + + +class ThemePipelineMixin(PipelineMixin): """ - Used by the ComprehensiveThemeFinder class. Mixes in support for cached - files and comprehensive theming in static files. + 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' """ - pass + packing = True + + def post_process(self, paths, dry_run=False, **options): + """ + This post_process hook is used to package all themed assets. + """ + if dry_run: + return + themes = get_themes() + + for theme in themes: + css_packages = self.get_themed_packages(theme.theme_dir_name, settings.PIPELINE_CSS) + js_packages = self.get_themed_packages(theme.theme_dir_name, settings.PIPELINE_JS) + + from pipeline.packager import Packager + packager = Packager(storage=self, css_packages=css_packages, js_packages=js_packages) + for package_name in packager.packages['css']: + package = packager.package_for('css', package_name) + output_file = package.output_filename + if self.packing: + packager.pack_stylesheets(package) + paths[output_file] = (self, output_file) + yield output_file, output_file, True + for package_name in packager.packages['js']: + package = packager.package_for('js', package_name) + output_file = package.output_filename + if self.packing: + packager.pack_javascripts(package) + paths[output_file] = (self, output_file) + yield output_file, output_file, True + + super_class = super(ThemePipelineMixin, self) + if hasattr(super_class, 'post_process'): + for name, hashed_name, processed in super_class.post_process(paths.copy(), dry_run, **options): + yield name, hashed_name, processed + + @staticmethod + def get_themed_packages(prefix, packages): + """ + Update paths with the themed assets, + Args: + prefix: theme prefix for which to update asset paths e.g. 'red-theme', 'edx.org' etc. + packages: packages to update + + Returns: list of updated paths and a boolean indicating whether any path was path or not + """ + themed_packages = {} + for name in packages: + # collect source file names for the package + source_files = [] + for path in packages[name].get('source_filenames', []): + # if themed asset exists use that, otherwise use default asset. + if find(os.path.join(prefix, path)): + source_files.append(os.path.join(prefix, path)) + else: + source_files.append(path) + + themed_packages[name] = { + 'output_filename': os.path.join(prefix, packages[name].get('output_filename', '')), + 'source_filenames': source_files, + } + return themed_packages diff --git a/openedx/core/djangoapps/theming/template_loaders.py b/openedx/core/djangoapps/theming/template_loaders.py new file mode 100644 index 0000000000000000000000000000000000000000..198388fa8c2c5fa809c1e6cae00f73a3d489a198 --- /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 0000000000000000000000000000000000000000..7beb99ca55fa87cc0df54c14c47582068a59c547 --- /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 0000000000000000000000000000000000000000..163c0de8e05824dacfd8cb4f1bbd869a21c8d0cb --- /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 0000000000000000000000000000000000000000..83497603f1205b90df47fbc52a0bb9f98f1d9d69 --- /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 536e9cc657f2d0e994ba7299c30cd315fb017bda..091f6fe9a71023f363b2bf5bcdc9e32e161f9867 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 TestHelpers(TestCase): + """Test comprehensive theming helper functions.""" -class ThemingHelpersTests(TestCase): - """ - Make sure some of the theming helper functions work - """ + 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 0000000000000000000000000000000000000000..f0d544743ff9d00c03f38e18e3aec3044047a7b8 --- /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 0000000000000000000000000000000000000000..72f81d9d9aafa3a31267f34af349e7587a0bef12 --- /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 6f13b9c1879c71aaac945dbe242d6eb7c32b4c2d..8ead84bd620cfe19946ace67c4621c1faec3d6a2 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.models import SiteTheme -from openedx.core.djangoapps.theming.core import comprehensive_theme_changes -EDX_THEME_DIR = settings.REPO_ROOT / "themes" / "edx.org" - - -def with_comprehensive_theme(theme_dir): +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 76dab0b94ee95ac5993476e84458373d1125d556..55025e357f1300f928d1ba025424efdfbfddf9bd 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 8d440ad14c3d49f42689b1e6d167fcb036f19ca2..4932f5295645ed600b56623178c3e832e7a9b4a7 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 93139e3fa4248d31d70ac00341541e121754c786..225e4b7e61038e11f8eb3611c137552c3a8996b7 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 c374537a7a42f773f6a6c10cf2c3acedcf8200fc..269b6b0a9a411761ff7c091bc5feb07a540d977f 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") +# system specific lookup path additions, add sass dirs if one system depends on the sass files for other systems +SASS_LOOKUP_DEPENDENCIES = { + 'cms': [path('lms') / 'static' / 'sass' / 'partials', ], +} -def configure_paths(): - """Configure our paths based on settings. Called immediately.""" - edxapp_env = Env() - if edxapp_env.feature_flags.get('USE_CUSTOM_THEME', False): - theme_name = edxapp_env.env_tokens.get('THEME_NAME', '') - parent_dir = path(edxapp_env.REPO_ROOT).abspath().parent - theme_root = parent_dir / "themes" / theme_name - COFFEE_DIRS.append(theme_root) - sass_dir = theme_root / "static" / "sass" - css_dir = theme_root / "static" / "css" - if sass_dir.isdir(): - css_dir.mkdir_p() - THEME_SASS_DIRECTORIES.append(sass_dir) - - if edxapp_env.env_tokens.get("COMPREHENSIVE_THEME_DIR", ""): - theme_dir = path(edxapp_env.env_tokens["COMPREHENSIVE_THEME_DIR"]) - lms_sass = theme_dir / "lms" / "static" / "sass" - lms_css = theme_dir / "lms" / "static" / "css" - if lms_sass.isdir(): - lms_css.mkdir_p() - THEME_SASS_DIRECTORIES.append(lms_sass) - cms_sass = theme_dir / "cms" / "static" / "sass" - cms_css = theme_dir / "cms" / "static" / "css" - if cms_sass.isdir(): - cms_css.mkdir_p() - THEME_SASS_DIRECTORIES.append(cms_sass) - -configure_paths() - - -def applicable_sass_directories(systems=None): - """ - Determine the applicable set of SASS directories to be - compiled for the specified list of systems. - Args: - systems: A list of systems (defaults to all) +def get_sass_directories(system, theme_dir=None): + """ + Determine the set of SASS directories to be compiled for the specified list of system and theme + and return a list of those directories. + + Each item in the list is dict object containing the following key-value pairs. + { + "sass_source_dir": "", # directory where source sass files are present + "css_destination_dir": "", # destination where css files would be placed + "lookup_paths": [], # list of directories to be passed as lookup paths for @import resolution. + } + + if theme_dir is empty or None then return sass directories for the given system only. (i.e. lms or cms) + + :param system: name if the system for which to compile sass e.g. 'lms', 'cms' + :param theme_dir: absolute path of theme for which to compile sass files. + """ + if system not in SYSTEMS: + raise ValueError("'system' must be one of ({allowed_values})".format(allowed_values=', '.join(SYSTEMS.keys()))) + system = SYSTEMS[system] + + applicable_directories = list() + + if theme_dir: + # Add theme sass directories + applicable_directories.extend( + get_theme_sass_dirs(system, theme_dir) + ) + else: + # add system sass directories + applicable_directories.extend( + get_system_sass_dirs(system) + ) + + return applicable_directories + + +def get_common_sass_directories(): + """ + Determine the set of common SASS directories to be compiled for all the systems and themes. + + Each item in the returned list is dict object containing the following key-value pairs. + { + "sass_source_dir": "", # directory where source sass files are present + "css_destination_dir": "", # destination where css files would be placed + "lookup_paths": [], # list of directories to be passed as lookup paths for @import resolution. + } + """ + applicable_directories = list() + + # add common sass directories + applicable_directories.append({ + "sass_source_dir": path("common/static/sass"), + "css_destination_dir": path("common/static/css"), + "lookup_paths": COMMON_LOOKUP_PATHS, + }) - Returns: - A list of SASS directories to be compiled. - """ - if not systems: - systems = ALL_SYSTEMS - applicable_directories = [] - applicable_directories.extend(COMMON_SASS_DIRECTORIES) - if "lms" in systems: - applicable_directories.extend(LMS_SASS_DIRECTORIES) - if "studio" in systems or "cms" in systems: - applicable_directories.extend(CMS_SASS_DIRECTORIES) - applicable_directories.extend(THEME_SASS_DIRECTORIES) return applicable_directories +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 b1b94e732b38271260e906488ba6cc37e6f85637..37f105993a6e807afb3b54f6f7809387cd09d0ac 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,18 +47,158 @@ 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/certificates/css/*.css") + expected_messages.append("libsass lms/static/certificates/sass") + if "studio" in system: + if force: + expected_messages.append("rm -rf cms/static/css/*.css") + expected_messages.append("libsass cms/static/sass") + + self.assertEquals(self.task_messages, expected_messages) + + +@ddt.ddt +class TestPaverThemeAssetTasks(PaverTestCase): + """ + Test the Paver asset tasks. + """ + @ddt.data( + [""], + ["--force"], + ["--debug"], + ["--system=lms"], + ["--system=lms --force"], + ["--system=studio"], + ["--system=studio --force"], + ["--system=lms,studio"], + ["--system=lms,studio --force"], + ) + @ddt.unpack + def test_compile_theme_sass(self, options): + """ + Test the "compile_sass" task. + """ + parameters = options.split(" ") + system = [] + + if "--system=studio" not in parameters: + system += ["lms"] + if "--system=lms" not in parameters: + system += ["studio"] + debug = "--debug" in parameters + force = "--force" in parameters + + self.reset_task_messages() + call_task( + 'pavelib.assets.compile_sass', + options={"system": system, "debug": debug, "force": force, "theme-dirs": [TEST_THEME.dirname()], + "themes": [TEST_THEME.basename()]}, + ) + expected_messages = [] + if force: + expected_messages.append("rm -rf common/static/css/*.css") + expected_messages.append("libsass common/static/sass") + + if "lms" in system: + expected_messages.append("mkdir_p " + repr(TEST_THEME / "lms/static/css")) + + if force: + expected_messages.append("rm -rf " + str(TEST_THEME) + "/lms/static/css/*.css") + expected_messages.append("libsass lms/static/sass") + if force: + expected_messages.append("rm -rf " + str(TEST_THEME) + "/lms/static/css/*.css") + expected_messages.append("libsass " + str(TEST_THEME) + "/lms/static/sass") if force: expected_messages.append("rm -rf lms/static/css/*.css") - expected_messages.append("libsass lms/static/themed_sass") + 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 be50696bf99d8aa1b04ccdb6f28b14cd7fff5da7..364c01c1195f49183a13a0b7144e1c6587006573 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 bf8dca355659183c7c3a4d4f7f2719d383063213..c22b26655d4b8a3b51500902c10c580bd4340cc9 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 90f42b62154410871996f991ac96e378555dacfd..162363e67f483b8590ef4295b791620b9cf3ef4a 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 0000000000000000000000000000000000000000..8f9274f7a4181154fb1eea3dd8c3130d7790a361 --- /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"> +<div class="wrapper-content wrapper"> + <section class="content"> + <header> + <h1 class="title title-1">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</h1> + <a href="${reverse('signup')}" class="action action-signin">${_("Don't have a {studio_name} Account? Sign up!").format(studio_name=settings.STUDIO_SHORT_NAME)}</a> + </header> + <!-- Login Page override for red-theme. --> + <article class="content-primary" role="main"> + <form id="login_form" method="post" action="login_post" onsubmit="return false;"> + + <fieldset> + <legend class="sr">${_("Required Information to Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</legend> + <input type="hidden" name="csrfmiddlewaretoken" value="${ csrf }" /> + + <ol class="list-input"> + <li class="field text required" id="field-email"> + <label for="email">${_("E-mail")}</label> + <input id="email" type="email" name="email" placeholder="${_('example: username@domain.com')}"/> + </li> + + <li class="field text required" id="field-password"> + <label for="password">${_("Password")}</label> + <input id="password" type="password" name="password" /> + <a href="${forgot_password_link}" class="action action-forgotpassword">${_("Forgot password?")}</a> + </li> + </ol> + </fieldset> + + <div class="form-actions"> + <button type="submit" id="submit" name="submit" class="action action-primary">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</button> + </div> + + <!-- no honor code for CMS, but need it because we're using the lms student object --> + <input name="honor_code" type="checkbox" value="true" checked="true" hidden="true"> + </form> + </article> + </section> +</div> +</%block> + +<%block name="requirejs"> + require(["js/factories/login"], function(LoginFactory) { + LoginFactory("${reverse('homepage')}"); + }); +</%block> 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 4e5e1f2b6e31a7c838afce2fddff339da1f6c35c..0000000000000000000000000000000000000000 --- 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 3eaad226a2b307f64b29bbd11aae3889b34174b4..0000000000000000000000000000000000000000 --- 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 d6287e821558213ded170840e794740d200c66c1..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..c869ff9856789ee3bfb117eb44faef7eb6c95c54 --- /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 69978fb3caf874b95d085dea8a170abe90d3e981..3570d929019b1ac7e70d52182bdf13c6b2e9d1cc 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"<a href='{}'>".format(marketing_link('HONOR')) %> ${ - _("{tos_link_start}Terms of Service{tos_link_end} and {honor_link_start}Honor Code{honor_link_end}").format( + _( + "{tos_link_start}Terms of Service{tos_link_end} and {honor_link_start}Honor Code{honor_link_end}").format( tos_link_start=tos_link, tos_link_end="</a>", honor_link_start=honor_link, diff --git a/themes/red-theme/lms/templates/header.html b/themes/red-theme/lms/templates/header.html index 864571cc5632192b1227ab5681cc59333d65f6ea..050ed074ab2d002a10b4740a1ba2cef76b6f5678 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> - <header id="global-navigation" class="global ${"slim" if course else ""}" > + <header id="global-navigation" class="header-global ${"slim" if course else ""}" > <!-- This file is only for demonstration, and is horrendous! --> <nav aria-label="${_('Global')}"> <h1 class="logo"> @@ -53,7 +54,7 @@ site_status_msg = get_site_status_msg(course_id) <% display_name = course.display_name_with_default_escaped if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): - ccx = get_current_ccx() + ccx = get_current_ccx(course.id) if ccx: display_name = ccx.display_name %> @@ -152,7 +153,7 @@ site_status_msg = get_site_status_msg(course_id) <li class="nav-courseware-01"> % if not settings.FEATURES['DISABLE_LOGIN_BUTTON']: % if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: - <a class="btn-brand btn-login" href="${reverse('course-specific-login', args=[course.id.to_deprecated_string()])}${login_query()}">${_("Sign in")}</a> + <a class="btn btn-login" href="${reverse('course-specific-login', args=[course.id.to_deprecated_string()])}${login_query()}">${_("Sign in")}</a> % else: <a class="btn-brand btn-login" href="/login${login_query()}">${_("Sign in")}</a> % endif @@ -164,7 +165,7 @@ site_status_msg = get_site_status_msg(course_id) </header> % if course: <!--[if lte IE 8]> -<div class="ie-banner" aria-hidden="true">${_('<strong>Warning:</strong> Your browser is not fully supported. We strongly recommend using {chrome_link} or {ff_link}.').format(chrome_link='<a href="https://www.google.com/intl/en/chrome/browser/" target="_blank">Chrome</a>', ff_link='<a href="http://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>')}</div> +<div class="ie-banner" aria-hidden="true">${_(HTML('<strong>Warning:</strong> Your browser is not fully supported. We strongly recommend using {chrome_link} or {ff_link}.')).format(chrome_link='<a href="https://www.google.com/intl/en/chrome/browser/" target="_blank">Chrome</a>', ff_link='<a href="http://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>')}</div> <![endif]--> % endif diff --git a/themes/stanford-style/lms/static/sass/lms-main-rtl.scss b/themes/stanford-style/lms/static/sass/lms-main-rtl.scss deleted file mode 100755 index 3eaad226a2b307f64b29bbd11aae3889b34174b4..0000000000000000000000000000000000000000 --- a/themes/stanford-style/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/stanford-style/lms/static/sass/lms-main.scss b/themes/stanford-style/lms/static/sass/lms-main.scss deleted file mode 100755 index d6287e821558213ded170840e794740d200c66c1..0000000000000000000000000000000000000000 --- a/themes/stanford-style/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/stanford-style/lms/static/sass/_overrides.scss b/themes/stanford-style/lms/static/sass/partials/base/_variables.scss similarity index 82% rename from themes/stanford-style/lms/static/sass/_overrides.scss rename to themes/stanford-style/lms/static/sass/partials/base/_variables.scss index 4bf94c998aa76f6cd01aa15610c3d3ff1d8686f4..6453fbb2b96685cefd8c1caf533c4aa600eaaad0 100755 --- a/themes/stanford-style/lms/static/sass/_overrides.scss +++ b/themes/stanford-style/lms/static/sass/partials/base/_variables.scss @@ -1,3 +1,5 @@ +@import 'lms/static/sass/partials/base/variables'; + // Theming overrides for sample theme $header-bg: rgb(140,21,21); $footer-bg: rgb(140,21,21); diff --git a/themes/stanford-style/lms/templates/index.html b/themes/stanford-style/lms/templates/index.html index b01a213eabb8def3037145b9da801db8783b3fcf..a9e368008e6ee78a81808c9e847fde29764774a4 100644 --- a/themes/stanford-style/lms/templates/index.html +++ b/themes/stanford-style/lms/templates/index.html @@ -1,6 +1,7 @@ <%inherit file="main.html" /> <%! from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import HTML %> <section class="home"> @@ -11,7 +12,7 @@ from django.utils.translation import ugettext as _ % if homepage_overlay_html: ${homepage_overlay_html} % else: - <h1>${_("Free courses from <strong>{university_name}</strong>").format(university_name="Stanford")}</h1> + <h1>${_(HTML("Free courses from <strong>{university_name}</strong>")).format(university_name="Stanford")}</h1> <p>${_("For anyone, anywhere, anytime")}</p> % endif </div> diff --git a/themes/stanford-style/lms/templates/register-shib.html b/themes/stanford-style/lms/templates/register-shib.html index 414d6743be04fd81c003d1a943991a7571d2bd74..6fd6b6b769fd0b143ab36f49b81427f3123bd497 100644 --- a/themes/stanford-style/lms/templates/register-shib.html +++ b/themes/stanford-style/lms/templates/register-shib.html @@ -2,6 +2,7 @@ <%! from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse +from openedx.core.djangolib.js_utils import js_escaped_string %> <%block name="pagetitle">${_("Preferences for {platform_name}").format(platform_name=settings.PLATFORM_NAME)}</%block> @@ -43,7 +44,7 @@ from django.core.urlresolvers import reverse }); $('#register-form').on('ajax:success', function(event, json, xhr) { - var url = json.redirect_url || "${reverse('dashboard')}"; + var url = json.redirect_url || "${reverse('dashboard') | n, js_escaped_string }"; location.href = url; }); @@ -65,14 +66,14 @@ from django.core.urlresolvers import reverse removeClass('is-disabled'). attr('aria-disabled', false). removeProp('disabled'). - text("${_('Update my {platform_name} Account').format(platform_name=settings.PLATFORM_NAME)}"); + text("${_('Update my {platform_name} Account').format(platform_name=settings.PLATFORM_NAME) | n, js_escaped_string }"); } else { $submitButton. addClass('is-disabled'). attr('aria-disabled', true). prop('disabled', true). - text("${_('Processing your account information')}"); + text("${_('Processing your account information') | n, js_escaped_string }"); } } </script>