From 4742e661f4ee07fc05d9d3be2a8b1a322309e169 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Thu, 21 May 2015 12:09:19 -0500 Subject: [PATCH] Creating a settings object that is aware of the microsite settings. Adding the declaration of the settings object to openedx.conf to be able to import it from a nicer location Resolving quality violations Merging dicts with the settings definition when they exist in the microsite configuration Using a cache to improve the perfomance of quering any dictionary in the microsite definition Ignoring the invalid-name pylint warning since the names must be kept thsi way to stay the same as the ones in django. Removing the default dict argument as per https://docs.python.org/2/tutorial/controlflow.html#default-argument-values Extracting the implementation of the microsite to a selectable backend. Leaving the function startup.enable_microsites for backwards compatibilityy Adding a database backend Using a cache to improve the perfomance of quering any dictionary in the microsite definition. Changed the database backend so that it extends the settings file backend and removed all the unnecessary methods. Using the backend provider for the get_dict function some tweeks and some initial unit tests Using getattr as a function insteal of calling the underlying __getattr__ directly Adding an ModelAdmin object for the microsite model in the django-admin panel refactor enable_microsites() consolidate/refactor some shared code add config to aws.py and add migration files fix tests Changes to get the backends to run after the refactor add archiving capabilities to microsites. Also make a few notes about performance improvements to make fix tests Making the query to find if microsites exist in the database faster add ORG to microsite mapping tables and some performance improvements allow for Mako templates to be pulled from the database fix tests For the database template backend the uri of the template does not use the filesystem relative path Fixing pylint violations Added caching of the templates stored in the database Fixing pylint errors fix pylint Clearing the cache on model save Fixing pylint errors rebased and added test coverage rebased cdodge/microsite-improvements branch with master and added test coverage added missing migration fix quality violations add more test coverage mattdrayer: Add microsite_configuration to cms.INSTALLED_APPS added microsite settings to cms/envs/test.py run session cookie tests only in LMS fixed broken tests putting middleware changes back Preventing the template_backend to be called on requests which have no microsite changes to address feedback from mjfrey changed BaseMicrositeBackend to AbstractBaseMicrositeBackend changes after feedback from mattdrayer fixed broken tests and quality violations Allowing the backend to handle the enable_pre_startup routine Typos and docstrings Adressing feedback Fixing python tests add comment to explain why we need enable_microsites_pre_startup() --- cms/envs/aws.py | 16 +- cms/envs/common.py | 19 + cms/envs/test.py | 44 ++- common/djangoapps/edxmako/paths.py | 13 + .../microsite_configuration/__init__.py | 31 ++ .../microsite_configuration/admin.py | 83 +++++ .../backends/__init__.py | 7 + .../microsite_configuration/backends/base.py | 340 ++++++++++++++++++ .../backends/database.py | 211 +++++++++++ .../backends/filebased.py | 26 ++ .../microsite_configuration/microsite.py | 185 +++++----- .../migrations/0001_initial.py | 107 ++++++ .../migrations/__init__.py | 0 .../microsite_configuration/models.py | 181 ++++++++++ .../tests/backends/__init__.py | 0 .../tests/backends/test_base.py | 132 +++++++ .../tests/backends/test_database.py | 220 ++++++++++++ .../tests/backends/test_filebased.py | 116 ++++++ .../tests/factories.py | 79 ++++ .../tests/test_logic.py | 44 ++- .../tests/test_microsites.py | 49 ++- .../tests/test_middleware.py | 59 ++- .../microsite_configuration/tests/tests.py | 41 +++ common/djangoapps/util/url.py | 7 + lms/envs/aws.py | 15 +- lms/envs/common.py | 16 + lms/envs/test.py | 9 +- lms/startup.py | 64 +--- 28 files changed, 1928 insertions(+), 186 deletions(-) create mode 100644 common/djangoapps/microsite_configuration/admin.py create mode 100644 common/djangoapps/microsite_configuration/backends/__init__.py create mode 100644 common/djangoapps/microsite_configuration/backends/base.py create mode 100644 common/djangoapps/microsite_configuration/backends/database.py create mode 100644 common/djangoapps/microsite_configuration/backends/filebased.py create mode 100644 common/djangoapps/microsite_configuration/migrations/0001_initial.py create mode 100644 common/djangoapps/microsite_configuration/migrations/__init__.py create mode 100644 common/djangoapps/microsite_configuration/models.py create mode 100644 common/djangoapps/microsite_configuration/tests/backends/__init__.py create mode 100644 common/djangoapps/microsite_configuration/tests/backends/test_base.py create mode 100644 common/djangoapps/microsite_configuration/tests/backends/test_database.py create mode 100644 common/djangoapps/microsite_configuration/tests/backends/test_filebased.py create mode 100644 common/djangoapps/microsite_configuration/tests/factories.py create mode 100644 common/djangoapps/microsite_configuration/tests/tests.py diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 9f4ab6aba97a..b1dcb64efe4a 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -312,9 +312,6 @@ MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED", 5) MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", 15 * 60) -MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {}) -MICROSITE_ROOT_DIR = path(ENV_TOKENS.get('MICROSITE_ROOT_DIR', '')) - #### PASSWORD POLICY SETTINGS ##### PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH") PASSWORD_MAX_LENGTH = ENV_TOKENS.get("PASSWORD_MAX_LENGTH") @@ -365,6 +362,19 @@ PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER) PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) +################# MICROSITE #################### +# microsite specific configurations. +MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {}) +MICROSITE_ROOT_DIR = path(ENV_TOKENS.get('MICROSITE_ROOT_DIR', '')) +# this setting specify which backend to be used when pulling microsite specific configuration +MICROSITE_BACKEND = ENV_TOKENS.get("MICROSITE_BACKEND", MICROSITE_BACKEND) +# this setting specify which backend to be used when loading microsite specific templates +MICROSITE_TEMPLATE_BACKEND = ENV_TOKENS.get("MICROSITE_TEMPLATE_BACKEND", MICROSITE_TEMPLATE_BACKEND) +# TTL for microsite database template cache +MICROSITE_DATABASE_TEMPLATE_CACHE_TTL = ENV_TOKENS.get( + "MICROSITE_DATABASE_TEMPLATE_CACHE_TTL", MICROSITE_DATABASE_TEMPLATE_CACHE_TTL +) + ############################ OAUTH2 Provider ################################### # OpenID Connect issuer ID. Normally the URL of the authentication endpoint. diff --git a/cms/envs/common.py b/cms/envs/common.py index b7b0c0c76e9c..7d6d853834e8 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -827,6 +827,10 @@ # other apps that are. Django 1.8 wants to have imported models supported # by installed apps. 'lms.djangoapps.verify_student', + + # Microsite configuration application + 'microsite_configuration', + ) @@ -1129,6 +1133,21 @@ 'graphical_slider_tool', ] + +################################ Settings for Microsites ################################ + +### Select an implementation for the microsite backend +# for MICROSITE_BACKEND possible choices are +# 1. microsite_configuration.backends.filebased.FilebasedMicrositeBackend +# 2. microsite_configuration.backends.database.DatabaseMicrositeBackend +MICROSITE_BACKEND = 'microsite_configuration.backends.filebased.FilebasedMicrositeBackend' +# for MICROSITE_TEMPLATE_BACKEND possible choices are +# 1. microsite_configuration.backends.filebased.FilebasedMicrositeTemplateBackend +# 2. microsite_configuration.backends.database.DatabaseMicrositeTemplateBackend +MICROSITE_TEMPLATE_BACKEND = 'microsite_configuration.backends.filebased.FilebasedMicrositeTemplateBackend' +# TTL for microsite database template cache +MICROSITE_DATABASE_TEMPLATE_CACHE_TTL = 5 * 60 + #### PROCTORING CONFIGURATION DEFAULTS PROCTORING_BACKEND_PROVIDER = { diff --git a/cms/envs/test.py b/cms/envs/test.py index 22ad41e1edc6..89c4586016cc 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -214,6 +214,8 @@ FEATURES['EMBARGO'] = True # set up some testing for microsites +FEATURES['USE_MICROSITES'] = True +MICROSITE_ROOT_DIR = COMMON_ROOT / 'test' / 'test_microsites' MICROSITE_CONFIGURATION = { "test_microsite": { "domain_prefix": "testmicrosite", @@ -231,15 +233,51 @@ "show_homepage_promo_video": False, "course_index_overlay_text": "This is a Test Microsite Overlay Text.", "course_index_overlay_logo_file": "test_microsite/images/header-logo.png", - "homepage_overlay_html": "

This is a Test Microsite Overlay HTML

" + "homepage_overlay_html": "

This is a Test Microsite Overlay HTML

", + "ALWAYS_REDIRECT_HOMEPAGE_TO_DASHBOARD_FOR_AUTHENTICATED_USER": False, + "COURSE_CATALOG_VISIBILITY_PERMISSION": "see_in_catalog", + "COURSE_ABOUT_VISIBILITY_PERMISSION": "see_about_page", + "ENABLE_SHOPPING_CART": True, + "ENABLE_PAID_COURSE_REGISTRATION": True, + "SESSION_COOKIE_DOMAIN": "test_microsite.localhost", + "urls": { + 'ABOUT': 'testmicrosite/about', + 'PRIVACY': 'testmicrosite/privacy', + 'TOS_AND_HONOR': 'testmicrosite/tos-and-honor', + }, + }, + "microsite_with_logistration": { + "domain_prefix": "logistration", + "university": "logistration", + "platform_name": "Test logistration", + "logo_image_url": "test_microsite/images/header-logo.png", + "email_from_address": "test_microsite@edx.org", + "payment_support_email": "test_microsite@edx.org", + "ENABLE_MKTG_SITE": False, + "ENABLE_COMBINED_LOGIN_REGISTRATION": True, + "SITE_NAME": "test_microsite.localhost", + "course_org_filter": "LogistrationX", + "course_about_show_social_links": False, + "css_overrides_file": "test_microsite/css/test_microsite.css", + "show_partners": False, + "show_homepage_promo_video": False, + "course_index_overlay_text": "Logistration.", + "course_index_overlay_logo_file": "test_microsite/images/header-logo.png", + "homepage_overlay_html": "

This is a Logistration HTML

", + "ALWAYS_REDIRECT_HOMEPAGE_TO_DASHBOARD_FOR_AUTHENTICATED_USER": False, + "COURSE_CATALOG_VISIBILITY_PERMISSION": "see_in_catalog", + "COURSE_ABOUT_VISIBILITY_PERMISSION": "see_about_page", + "ENABLE_SHOPPING_CART": True, + "ENABLE_PAID_COURSE_REGISTRATION": True, + "SESSION_COOKIE_DOMAIN": "test_logistration.localhost", }, "default": { "university": "default_university", "domain_prefix": "www", } } -MICROSITE_ROOT_DIR = COMMON_ROOT / 'test' / 'test_microsites' -FEATURES['USE_MICROSITES'] = True +MICROSITE_TEST_HOSTNAME = 'testmicrosite.testserver' +MICROSITE_LOGISTRATION_HOSTNAME = 'logistration.testserver' # For consistency in user-experience, keep the value of this setting in sync with # the one in lms/envs/test.py diff --git a/common/djangoapps/edxmako/paths.py b/common/djangoapps/edxmako/paths.py index 18ddb9eafd31..3e7bb40430e2 100644 --- a/common/djangoapps/edxmako/paths.py +++ b/common/djangoapps/edxmako/paths.py @@ -10,6 +10,7 @@ from django.conf import settings from mako.lookup import TemplateLookup +from microsite_configuration import microsite from . import LOOKUP @@ -46,6 +47,18 @@ def add_directory(self, directory, prepend=False): self._collection.clear() self._uri_cache.clear() + def get_template(self, uri): + """ + Overridden method which will hand-off the template lookup to the microsite subsystem + """ + microsite_template = microsite.get_template(uri) + + return ( + microsite_template + if microsite_template + else super(DynamicTemplateLookup, self).get_template(uri) + ) + def clear_lookups(namespace): """ diff --git a/common/djangoapps/microsite_configuration/__init__.py b/common/djangoapps/microsite_configuration/__init__.py index cc081c890a44..cd0cc601f9b4 100644 --- a/common/djangoapps/microsite_configuration/__init__.py +++ b/common/djangoapps/microsite_configuration/__init__.py @@ -1 +1,32 @@ +""" +This file implements a class which is a handy utility to make any +call to the settings completely microsite aware by replacing the: + +from django.conf import settings + +with: + +from microsite_configuration import settings +""" +from django.conf import settings as base_settings + +from microsite_configuration import microsite from .templatetags.microsite import page_title_breadcrumbs + + +class MicrositeAwareSettings(object): + """ + This class is a proxy object of the settings object from django. + It will try to get a value from the microsite and default to the + django settings + """ + + def __getattr__(self, name): + try: + if isinstance(microsite.get_value(name), dict): + return microsite.get_dict(name, getattr(base_settings, name)) + return microsite.get_value(name, getattr(base_settings, name)) + except KeyError: + return getattr(base_settings, name) + +settings = MicrositeAwareSettings() # pylint: disable=invalid-name diff --git a/common/djangoapps/microsite_configuration/admin.py b/common/djangoapps/microsite_configuration/admin.py new file mode 100644 index 000000000000..d9d175912848 --- /dev/null +++ b/common/djangoapps/microsite_configuration/admin.py @@ -0,0 +1,83 @@ +""" +Django admin page for microsite models +""" +from django.contrib import admin +from django import forms + +from .models import ( + Microsite, + MicrositeHistory, + MicrositeOrganizationMapping, + MicrositeTemplate +) +from util.organizations_helpers import get_organizations + + +class MicrositeAdmin(admin.ModelAdmin): + """ Admin interface for the Microsite object. """ + list_display = ('key', 'site') + search_fields = ('site__domain', 'values') + + class Meta(object): # pylint: disable=missing-docstring + model = Microsite + + +class MicrositeHistoryAdmin(admin.ModelAdmin): + """ Admin interface for the MicrositeHistory object. """ + list_display = ('key', 'site', 'created') + search_fields = ('site__domain', 'values') + + ordering = ['-created'] + + class Meta(object): # pylint: disable=missing-docstring + model = MicrositeHistory + + def has_add_permission(self, request): + """Don't allow adds""" + return False + + def has_delete_permission(self, request, obj=None): + """Don't allow deletes""" + return False + + +class MicrositeOrganizationMappingForm(forms.ModelForm): + """ + Django admin form for MicrositeOrganizationMapping model + """ + def __init__(self, *args, **kwargs): + super(MicrositeOrganizationMappingForm, self).__init__(*args, **kwargs) + organizations = get_organizations() + org_choices = [(org["short_name"], org["name"]) for org in organizations] + org_choices.insert(0, ('', 'None')) + self.fields['organization'] = forms.TypedChoiceField( + choices=org_choices, required=False, empty_value=None + ) + + class Meta(object): + model = MicrositeOrganizationMapping + fields = '__all__' + + +class MicrositeOrganizationMappingAdmin(admin.ModelAdmin): + """ Admin interface for the MicrositeOrganizationMapping object. """ + list_display = ('organization', 'microsite') + search_fields = ('organization', 'microsite') + form = MicrositeOrganizationMappingForm + + class Meta(object): # pylint: disable=missing-docstring + model = MicrositeOrganizationMapping + + +class MicrositeTemplateAdmin(admin.ModelAdmin): + """ Admin interface for the MicrositeTemplate object. """ + list_display = ('microsite', 'template_uri') + search_fields = ('microsite', 'template_uri') + + class Meta(object): # pylint: disable=missing-docstring + model = MicrositeTemplate + +admin.site.register(Microsite, MicrositeAdmin) +admin.site.register(MicrositeHistory, MicrositeHistoryAdmin) +admin.site.register(MicrositeOrganizationMapping, MicrositeOrganizationMappingAdmin) +admin.site.register(MicrositeTemplate, MicrositeTemplateAdmin) diff --git a/common/djangoapps/microsite_configuration/backends/__init__.py b/common/djangoapps/microsite_configuration/backends/__init__.py new file mode 100644 index 000000000000..00ec11f8107c --- /dev/null +++ b/common/djangoapps/microsite_configuration/backends/__init__.py @@ -0,0 +1,7 @@ +""" +Supported backends for microsites +1. filebased + This backend supports retrieval of microsite configurations/templates from filesystem. +2. database + This backend supports retrieval of microsite configurations/templates from database. +""" diff --git a/common/djangoapps/microsite_configuration/backends/base.py b/common/djangoapps/microsite_configuration/backends/base.py new file mode 100644 index 000000000000..20cc25e13613 --- /dev/null +++ b/common/djangoapps/microsite_configuration/backends/base.py @@ -0,0 +1,340 @@ +""" +Microsite configuration backend module. + +Contains the base classes for microsite backends. + +AbstractBaseMicrositeBackend is Abstract Base Class for the microsite configuration backend. +BaseMicrositeBackend is Base Class for microsite configuration backend. +BaseMicrositeTemplateBackend is Base Class for the microsite template backend. +""" + +from __future__ import absolute_import + +import abc +import edxmako +import os.path +import threading + +from django.conf import settings + +from util.url import strip_port_from_host + + +# pylint: disable=unused-argument +class AbstractBaseMicrositeBackend(object): + """ + Abstract Base Class for the microsite backends. + """ + __metaclass__ = abc.ABCMeta + + def __init__(self, **kwargs): + pass + + @abc.abstractmethod + def set_config_by_domain(self, domain): + """ + For a given request domain, find a match in our microsite configuration + and make it available to the complete django request process + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_value(self, val_name, default=None, **kwargs): + """ + Returns a value associated with the request's microsite, if present + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_dict(self, dict_name, default=None, **kwargs): + """ + Returns a dictionary product of merging the request's microsite and + the default value. + This can be used, for example, to return a merged dictonary from the + settings.FEATURES dict, including values defined at the microsite + """ + raise NotImplementedError() + + @abc.abstractmethod + def is_request_in_microsite(self): + """ + This will return True/False if the current request is a request within a microsite + """ + raise NotImplementedError() + + @abc.abstractmethod + def has_override_value(self, val_name): + """ + Returns True/False whether a Microsite has a definition for the + specified named value + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_all_config(self): + """ + This returns a set of orgs that are considered within all microsites. + This can be used, for example, to do filtering + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_value_for_org(self, org, val_name, default=None): + """ + This returns a configuration value for a microsite which has an org_filter that matches + what is passed in + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_all_orgs(self): + """ + This returns a set of orgs that are considered within a microsite. This can be used, + for example, to do filtering + """ + raise NotImplementedError() + + @abc.abstractmethod + def clear(self): + """ + Clears out any microsite configuration from the current request/thread + """ + raise NotImplementedError() + + +class BaseMicrositeBackend(AbstractBaseMicrositeBackend): + """ + Base class for Microsite backends. + """ + + def __init__(self, **kwargs): + super(BaseMicrositeBackend, self).__init__(**kwargs) + self.current_request_configuration = threading.local() + self.current_request_configuration.data = {} + self.current_request_configuration.cache = {} + + def has_configuration_set(self): + """ + Returns whether there is any Microsite configuration settings + """ + return getattr(settings, "MICROSITE_CONFIGURATION", False) + + def get_configuration(self): + """ + Returns the current request's microsite configuration. + if request's microsite configuration is not present returns empty dict. + """ + if not hasattr(self.current_request_configuration, 'data'): + return {} + + return self.current_request_configuration.data + + def get_key_from_cache(self, key): + """ + Retrieves a key from a cache scoped to the thread + """ + if hasattr(self.current_request_configuration, 'cache'): + return self.current_request_configuration.cache.get(key) + + def set_key_to_cache(self, key, value): + """ + Stores a key value pair in a cache scoped to the thread + """ + if hasattr(self.current_request_configuration, 'cache'): + self.current_request_configuration.cache[key] = value + + def set_config_by_domain(self, domain): + """ + For a given request domain, find a match in our microsite configuration + and then assign it to the thread local in order to make it available + to the complete Django request processing + """ + if not self.has_configuration_set() or not domain: + return + + for key, value in settings.MICROSITE_CONFIGURATION.items(): + subdomain = value.get('domain_prefix') + if subdomain and domain.startswith(subdomain): + self._set_microsite_config(key, subdomain, domain) + return + + # if no match on subdomain then see if there is a 'default' microsite defined + # if so, then use that + if 'default' in settings.MICROSITE_CONFIGURATION: + self._set_microsite_config('default', subdomain, domain) + return + + def get_value(self, val_name, default=None, **kwargs): + """ + Returns a value associated with the request's microsite, if present + """ + configuration = self.get_configuration() + return configuration.get(val_name, default) + + def get_dict(self, dict_name, default=None, **kwargs): + """ + Returns a dictionary product of merging the request's microsite and + the default value. + Supports storing a cache of the merged value to improve performance + """ + cached_dict = self.get_key_from_cache(dict_name) + if cached_dict: + return cached_dict + + default = default or {} + output = default.copy() + output.update(self.get_value(dict_name, {})) + + self.set_key_to_cache(dict_name, output) + return output + + def is_request_in_microsite(self): + """ + This will return if current request is a request within a microsite + """ + return bool(self.get_configuration()) + + def has_override_value(self, val_name): + """ + Will return True/False whether a Microsite has a definition for the + specified val_name + """ + configuration = self.get_configuration() + return val_name in configuration + + def get_all_config(self): + """ + This returns all configuration for all microsites + """ + config = {} + + for key, value in settings.MICROSITE_CONFIGURATION.iteritems(): + config[key] = value + + return config + + def get_value_for_org(self, org, val_name, default=None): + """ + This returns a configuration value for a microsite which has an org_filter that matches + what is passed in + """ + + if not self.has_configuration_set(): + return default + + # Filter at the setting file + for value in settings.MICROSITE_CONFIGURATION.itervalues(): + org_filter = value.get('course_org_filter', None) + if org_filter == org: + return value.get(val_name, default) + return default + + def get_all_orgs(self): + """ + This returns a set of orgs that are considered within a microsite. This can be used, + for example, to do filtering + """ + org_filter_set = set() + + if not self.has_configuration_set(): + return org_filter_set + + # Get the orgs in the db + for microsite in settings.MICROSITE_CONFIGURATION.itervalues(): + org_filter = microsite.get('course_org_filter') + if org_filter: + org_filter_set.add(org_filter) + + return org_filter_set + + def _set_microsite_config(self, microsite_config_key, subdomain, domain): + """ + Helper internal method to actually find the microsite configuration + """ + config = settings.MICROSITE_CONFIGURATION[microsite_config_key].copy() + config['subdomain'] = strip_port_from_host(subdomain) + config['microsite_config_key'] = microsite_config_key + config['site_domain'] = strip_port_from_host(domain) + + template_dir = settings.MICROSITE_ROOT_DIR / microsite_config_key / 'templates' + config['template_dir'] = template_dir + self.current_request_configuration.data = config + + def clear(self): + """ + Clears out any microsite configuration from the current request/thread + """ + self.current_request_configuration.data = {} + self.current_request_configuration.cache = {} + + def enable_microsites(self, log): + """ + 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) + else: + log.error( + 'Error loading %s. Directory does not exist', + microsites_root + ) + + def enable_microsites_pre_startup(self, log): + """ + The TEMPLATE_ENGINE directory to search for microsite templates + in non-mako templates must be loaded before the django startup + """ + microsites_root = settings.MICROSITE_ROOT_DIR + microsite_config_dict = settings.MICROSITE_CONFIGURATION + + if microsite_config_dict: + settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].append(microsites_root) + + +class BaseMicrositeTemplateBackend(object): + """ + Interface for microsite template providers. Base implementation is to use the filesystem. + When this backend is used templates are first searched in location set in `template_dir` + configuration of microsite on filesystem. + """ + + def get_template_path(self, relative_path, **kwargs): + """ + Returns a path (string) to a Mako template, which can either be in + an override or will just return what is passed in which is expected to be a string + """ + + from microsite_configuration.microsite import get_value as microsite_get_value + + microsite_template_path = microsite_get_value('template_dir', None) + + if not microsite_template_path: + microsite_template_path = '/'.join([ + settings.MICROSITE_ROOT_DIR, + microsite_get_value('microsite_config_key', 'default'), + 'templates', + ]) + + search_path = os.path.join(microsite_template_path, relative_path) + if os.path.isfile(search_path): + path = '/{0}/templates/{1}'.format( + microsite_get_value('microsite_config_key'), + relative_path + ) + return path + else: + return relative_path + + def get_template(self, uri): + """ + Returns the actual template for the microsite with the specified URI, + default implementation returns None, which means that the caller framework + should use default behavior + """ + + return diff --git a/common/djangoapps/microsite_configuration/backends/database.py b/common/djangoapps/microsite_configuration/backends/database.py new file mode 100644 index 000000000000..0d9a7ec5e1d2 --- /dev/null +++ b/common/djangoapps/microsite_configuration/backends/database.py @@ -0,0 +1,211 @@ +""" +Microsite backend that reads the configuration from the database +""" +from mako.template import Template +from util.cache import cache + +from django.conf import settings +from django.dispatch import receiver +from django.db.models.signals import post_save + +from util.memcache import fasthash +from util.url import strip_port_from_host +from microsite_configuration.backends.base import ( + BaseMicrositeBackend, + BaseMicrositeTemplateBackend, +) +from microsite_configuration.models import ( + Microsite, + MicrositeOrganizationMapping, + MicrositeTemplate +) +from microsite_configuration.microsite import get_value as microsite_get_value + + +class DatabaseMicrositeBackend(BaseMicrositeBackend): + """ + Microsite backend that reads the microsites definitions + from a table in the database according to the models.py file + This backend would allow us to save microsite configurations + into database and load them in local storage when HTTRequest + is originated from microsite. + + E.g. we have setup a microsite with key `monster-university-academy` and + We would have a DB entry like this in table created by Microsite model. + + key = monster-university-academy + subdomain = mua.edx.org + values = { + "platform_name": "Monster University Academy". + "course_org_filter: "MonsterX" + } + + While using DatabaseMicrositeBackend any request coming from mua.edx.org + would get microsite configurations from `values` column. + """ + + def has_configuration_set(self): + """ + Returns whether there is any Microsite configuration settings + """ + if Microsite.objects.all()[:1].exists(): + return True + else: + return False + + def set_config_by_domain(self, domain): + """ + For a given request domain, find a match in our microsite configuration + and then assign it to the thread local in order to make it available + to the complete Django request processing + """ + + if not self.has_configuration_set() or not domain: + return + + # look up based on the HTTP request domain name + # this will need to be a full domain name match, + # not a 'startswith' match + microsite = Microsite.get_microsite_for_domain(domain) + + if not microsite: + # if no match, then try to find a 'default' key in Microsites + try: + microsite = Microsite.objects.get(key='default') + except Microsite.DoesNotExist: + pass + + if microsite: + # if we have a match, then set up the microsite thread local + # data + self._set_microsite_config_from_obj(microsite.site.domain, domain, microsite) + + def get_all_config(self): + """ + This returns all configuration for all microsites + """ + config = {} + + candidates = Microsite.objects.all() + for microsite in candidates: + values = microsite.values + config[microsite.key] = values + + return config + + def get_value_for_org(self, org, val_name, default=None): + """ + This returns a configuration value for a microsite which has an org_filter that matches + what is passed in + """ + + microsite = MicrositeOrganizationMapping.get_microsite_for_organization(org) + if not microsite: + return default + + # cdodge: This approach will not leverage any caching, although I think only Studio calls + # this + config = microsite.values + return config.get(val_name, default) + + def get_all_orgs(self): + """ + This returns a set of orgs that are considered within a microsite. This can be used, + for example, to do filtering + """ + + # This should be cacheable (via memcache to keep consistent across a cluster) + # I believe this is called on the dashboard and catalog pages, so it'd be good to optimize + return set(MicrositeOrganizationMapping.objects.all().values_list('organization', flat=True)) + + def _set_microsite_config_from_obj(self, subdomain, domain, microsite_object): + """ + Helper internal method to actually find the microsite configuration + """ + config = microsite_object.values + config['subdomain'] = strip_port_from_host(subdomain) + config['site_domain'] = strip_port_from_host(domain) + config['microsite_config_key'] = microsite_object.key + + # we take the list of ORGs associated with this microsite from the database mapping + # tables. NOTE, for now, we assume one ORG per microsite + organizations = microsite_object.get_organizations() + + # we must have at least one ORG defined + if not organizations: + raise Exception( + 'Configuration error. Microsite {key} does not have any ORGs mapped to it!'.format( + key=microsite_object.key + ) + ) + + # just take the first one for now, we'll have to change the upstream logic to allow + # for more than one ORG binding + config['course_org_filter'] = organizations[0] + self.current_request_configuration.data = config + + +class DatabaseMicrositeTemplateBackend(BaseMicrositeTemplateBackend): + """ + Specialized class to pull templates from the database. + This Backend would allow us to save templates in DB and pull + them from there when required for a specific microsite. + This backend can be enabled by `MICROSITE_TEMPLATE_BACKEND` setting. + + E.g. we have setup a microsite for subdomain `mua.edx.org` and + We have a DB entry like this in table created by MicrositeTemplate model. + + microsite = Key for microsite(mua.edx.org) + template_uri = about.html + template = Template from DB + + While using DatabaseMicrositeTemplateBackend any request coming from mua.edx.org/about.html + would get about.html template from DB and response would be the value of `template` column. + """ + def get_template_path(self, relative_path, **kwargs): + return relative_path + + def get_template(self, uri): + """ + Override of the base class for us to look into the + database tables for a template definition, if we can't find + one we'll return None which means "use default means" (aka filesystem) + """ + cache_key = "template_cache." + fasthash(microsite_get_value('site_domain') + '.' + uri) + template_text = cache.get(cache_key) # pylint: disable=maybe-no-member + + if not template_text: + # cache is empty so pull template from DB and fill cache. + template_obj = MicrositeTemplate.get_template_for_microsite( + microsite_get_value('site_domain'), + uri + ) + + if not template_obj: + # We need to set something in the cache to improve performance + # of the templates stored in the filesystem as well + cache.set( # pylint: disable=maybe-no-member + cache_key, '##none', settings.MICROSITE_DATABASE_TEMPLATE_CACHE_TTL + ) + return None + + template_text = template_obj.template + cache.set( # pylint: disable=maybe-no-member + cache_key, template_text, settings.MICROSITE_DATABASE_TEMPLATE_CACHE_TTL + ) + + if template_text == '##none': + return None + + return Template( + text=template_text + ) + + @staticmethod + @receiver(post_save, sender=MicrositeTemplate) + def clear_cache(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Clear the cached template when the model is saved + """ + cache_key = "template_cache." + fasthash(instance.microsite.site.domain + '.' + instance.template_uri) + cache.delete(cache_key) # pylint: disable=maybe-no-member diff --git a/common/djangoapps/microsite_configuration/backends/filebased.py b/common/djangoapps/microsite_configuration/backends/filebased.py new file mode 100644 index 000000000000..3c5b012643e0 --- /dev/null +++ b/common/djangoapps/microsite_configuration/backends/filebased.py @@ -0,0 +1,26 @@ +""" +Microsite backend that reads the configuration from a file + +""" + +from microsite_configuration.backends.base import ( + BaseMicrositeBackend, + BaseMicrositeTemplateBackend, +) + + +class FilebasedMicrositeBackend(BaseMicrositeBackend): + """ + Microsite backend that reads the microsites definitions + from a dictionary called MICROSITE_CONFIGURATION in the settings file. + """ + + def __init__(self, **kwargs): + super(FilebasedMicrositeBackend, self).__init__(**kwargs) + + +class FilebasedMicrositeTemplateBackend(BaseMicrositeTemplateBackend): + """ + Microsite backend that loads templates from filesystem. + """ + pass diff --git a/common/djangoapps/microsite_configuration/microsite.py b/common/djangoapps/microsite_configuration/microsite.py index 01759ebe9b90..35716d789912 100644 --- a/common/djangoapps/microsite_configuration/microsite.py +++ b/common/djangoapps/microsite_configuration/microsite.py @@ -6,79 +6,61 @@ 2) Present a landing page with a listing of courses that are specific to the 'brand' 3) Ability to swap out some branding elements in the website """ -import threading -import os.path +import inspect +from importlib import import_module from django.conf import settings +from microsite_configuration.backends.base import BaseMicrositeBackend, BaseMicrositeTemplateBackend -CURRENT_REQUEST_CONFIGURATION = threading.local() -CURRENT_REQUEST_CONFIGURATION.data = {} +__all__ = [ + 'is_request_in_microsite', 'get_value', 'has_override_value', + 'get_template_path', 'get_value_for_org', 'get_all_orgs', + 'clear', 'set_by_domain', 'enable_microsites', 'get_all_config', + 'is_feature_enabled', 'enable_microsites_pre_startup', +] -def has_configuration_set(): - """ - Returns whether there is any Microsite configuration settings - """ - return getattr(settings, "MICROSITE_CONFIGURATION", False) +BACKEND = None +TEMPLATES_BACKEND = None -def get_configuration(): +def is_feature_enabled(): """ - Returns the current request's microsite configuration + Returns whether the feature flag to enable microsite has been set """ - if not hasattr(CURRENT_REQUEST_CONFIGURATION, 'data'): - return {} - - return CURRENT_REQUEST_CONFIGURATION.data + return settings.FEATURES.get('USE_MICROSITES', False) def is_request_in_microsite(): """ This will return if current request is a request within a microsite """ - return bool(get_configuration()) + return BACKEND.is_request_in_microsite() -def get_value(val_name, default=None): +def get_value(val_name, default=None, **kwargs): """ Returns a value associated with the request's microsite, if present """ - configuration = get_configuration() - return configuration.get(val_name, default) + return BACKEND.get_value(val_name, default, **kwargs) -def has_override_value(val_name): +def get_dict(dict_name, default=None, **kwargs): """ - Returns True/False whether a Microsite has a definition for the - specified named value + Returns a dictionary product of merging the request's microsite and + the default value. + This can be used, for example, to return a merged dictonary from the + settings.FEATURES dict, including values defined at the microsite """ - configuration = get_configuration() - return val_name in configuration + return BACKEND.get_dict(dict_name, default, **kwargs) -def get_template_path(relative_path): +def has_override_value(val_name): """ - Returns a path (string) to a Mako template, which can either be in - a microsite directory (as an override) or will just return what is passed in which is - expected to be a string + Returns True/False whether a Microsite has a definition for the + specified named value """ - - if not is_request_in_microsite(): - return relative_path - - microsite_template_path = str(get_value('template_dir')) - - if microsite_template_path: - search_path = os.path.join(microsite_template_path, relative_path) - - if os.path.isfile(search_path): - path = '/{0}/templates/{1}'.format( - get_value('microsite_name'), - relative_path - ) - return path - - return relative_path + return BACKEND.has_override_value(val_name) def get_value_for_org(org, val_name, default=None): @@ -86,14 +68,7 @@ def get_value_for_org(org, val_name, default=None): This returns a configuration value for a microsite which has an org_filter that matches what is passed in """ - if not has_configuration_set(): - return default - - for value in settings.MICROSITE_CONFIGURATION.values(): - org_filter = value.get('course_org_filter', None) - if org_filter == org: - return value.get(val_name, default) - return default + return BACKEND.get_value_for_org(org, val_name, default) def get_all_orgs(): @@ -101,52 +76,96 @@ def get_all_orgs(): This returns a set of orgs that are considered within a microsite. This can be used, for example, to do filtering """ - org_filter_set = set() - if not has_configuration_set(): - return org_filter_set + return BACKEND.get_all_orgs() - for value in settings.MICROSITE_CONFIGURATION.values(): - org_filter = value.get('course_org_filter') - if org_filter: - org_filter_set.add(org_filter) - return org_filter_set +def get_all_config(): + """ + This returns a dict have all microsite configs. Each key in the dict represent a + microsite config. + """ + return BACKEND.get_all_config() def clear(): """ Clears out any microsite configuration from the current request/thread """ - CURRENT_REQUEST_CONFIGURATION.data = {} + BACKEND.clear() -def _set_current_microsite(microsite_config_key, subdomain, domain): +def set_by_domain(domain): """ - Helper internal method to actually put a microsite on the threadlocal + For a given request domain, find a match in our microsite configuration + and make it available to the complete django request process """ - config = settings.MICROSITE_CONFIGURATION[microsite_config_key].copy() - config['subdomain'] = subdomain - config['microsite_config_key'] = microsite_config_key - config['site_domain'] = domain - CURRENT_REQUEST_CONFIGURATION.data = config + BACKEND.set_config_by_domain(domain) -def set_by_domain(domain): +def enable_microsites_pre_startup(log): """ - For a given request domain, find a match in our microsite configuration and then assign - it to the thread local so that it is available throughout the entire - Django request processing + Prepare the feature settings that must be enabled before django.setup() or + autostartup() during the startup script """ - if not has_configuration_set() or not domain: + if is_feature_enabled(): + BACKEND.enable_microsites_pre_startup(log) + + +def enable_microsites(log): + """ + Enable the use of microsites during the startup script + """ + if is_feature_enabled(): + BACKEND.enable_microsites(log) + + +def get_template(uri): + """ + Returns a template for the specified URI, None if none exists or if caller should + use default templates/search paths + """ + if not is_request_in_microsite(): return - for key, value in settings.MICROSITE_CONFIGURATION.items(): - subdomain = value.get('domain_prefix') - if subdomain and domain.startswith(subdomain): - _set_current_microsite(key, subdomain, domain) - return + return TEMPLATES_BACKEND.get_template(uri) + + +def get_template_path(relative_path, **kwargs): + """ + Returns a path (string) to a template + """ + if not is_request_in_microsite(): + return relative_path + + return TEMPLATES_BACKEND.get_template_path(relative_path, **kwargs) + + +def get_backend(name, expected_base_class, **kwds): + """ + Load a microsites backend and return an instance of it. + If backend is None (default) settings.MICROSITE_BACKEND is used. + Any additional args(kwds) will be used in the constructor of the backend. + """ + if not name: + return None + + try: + parts = name.split('.') + module_name = '.'.join(parts[:-1]) + class_name = parts[-1] + except IndexError: + raise ValueError('Invalid microsites backend %s' % name) + + try: + module = import_module(module_name) + cls = getattr(module, class_name) + if not inspect.isclass(cls) or not issubclass(cls, expected_base_class): + raise TypeError + except (AttributeError, ValueError): + raise ValueError('Cannot find microsites backend %s' % module_name) + + return cls(**kwds) + - # if no match on subdomain then see if there is a 'default' microsite defined - # if so, then use that - if 'default' in settings.MICROSITE_CONFIGURATION: - _set_current_microsite('default', subdomain, domain) +BACKEND = get_backend(settings.MICROSITE_BACKEND, BaseMicrositeBackend) +TEMPLATES_BACKEND = get_backend(settings.MICROSITE_TEMPLATE_BACKEND, BaseMicrositeTemplateBackend) diff --git a/common/djangoapps/microsite_configuration/migrations/0001_initial.py b/common/djangoapps/microsite_configuration/migrations/0001_initial.py new file mode 100644 index 000000000000..94d1e4a968d3 --- /dev/null +++ b/common/djangoapps/microsite_configuration/migrations/0001_initial.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields +import django.db.models.deletion +from django.conf import settings +import model_utils.fields +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalMicrositeOrganizationMapping', + fields=[ + ('id', models.IntegerField(verbose_name='ID', db_index=True, auto_created=True, blank=True)), + ('organization', models.CharField(max_length=63, db_index=True)), + ('history_id', models.AutoField(serialize=False, primary_key=True)), + ('history_date', models.DateTimeField()), + ('history_type', models.CharField(max_length=1, choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')])), + ('history_user', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical microsite organization mapping', + }, + ), + migrations.CreateModel( + name='HistoricalMicrositeTemplate', + fields=[ + ('id', models.IntegerField(verbose_name='ID', db_index=True, auto_created=True, blank=True)), + ('template_uri', models.CharField(max_length=255, db_index=True)), + ('template', models.TextField()), + ('history_id', models.AutoField(serialize=False, primary_key=True)), + ('history_date', models.DateTimeField()), + ('history_type', models.CharField(max_length=1, choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')])), + ('history_user', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical microsite template', + }, + ), + migrations.CreateModel( + name='Microsite', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('key', models.CharField(unique=True, max_length=63, db_index=True)), + ('values', jsonfield.fields.JSONField(blank=True)), + ('site', models.OneToOneField(related_name='microsite', to='sites.Site')), + ], + ), + migrations.CreateModel( + name='MicrositeHistory', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), + ('key', models.CharField(unique=True, max_length=63, db_index=True)), + ('values', jsonfield.fields.JSONField(blank=True)), + ('site', models.OneToOneField(related_name='microsite_history', to='sites.Site')), + ], + options={ + 'verbose_name_plural': 'Microsite histories', + }, + ), + migrations.CreateModel( + name='MicrositeOrganizationMapping', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('organization', models.CharField(unique=True, max_length=63, db_index=True)), + ('microsite', models.ForeignKey(to='microsite_configuration.Microsite')), + ], + ), + migrations.CreateModel( + name='MicrositeTemplate', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('template_uri', models.CharField(max_length=255, db_index=True)), + ('template', models.TextField()), + ('microsite', models.ForeignKey(to='microsite_configuration.Microsite')), + ], + ), + migrations.AddField( + model_name='historicalmicrositetemplate', + name='microsite', + field=models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='microsite_configuration.Microsite', null=True), + ), + migrations.AddField( + model_name='historicalmicrositeorganizationmapping', + name='microsite', + field=models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='microsite_configuration.Microsite', null=True), + ), + migrations.AlterUniqueTogether( + name='micrositetemplate', + unique_together=set([('microsite', 'template_uri')]), + ), + ] diff --git a/common/djangoapps/microsite_configuration/migrations/__init__.py b/common/djangoapps/microsite_configuration/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/djangoapps/microsite_configuration/models.py b/common/djangoapps/microsite_configuration/models.py new file mode 100644 index 000000000000..54bf397449c7 --- /dev/null +++ b/common/djangoapps/microsite_configuration/models.py @@ -0,0 +1,181 @@ +""" +Model to store a microsite in the database. + +The object is stored as a json representation of the python dict +that would have been used in the settings. + +""" +import collections + +from django.db import models +from django.dispatch import receiver +from django.db.models.signals import pre_save, pre_delete +from django.db.models.base import ObjectDoesNotExist +from django.contrib.sites.models import Site + +from jsonfield.fields import JSONField +from model_utils.models import TimeStampedModel +from simple_history.models import HistoricalRecords + + +class Microsite(models.Model): + """ + This is where the information about the microsite gets stored to the db. + To achieve the maximum flexibility, most of the fields are stored inside + a json field. + + Notes: + - The key field was required for the dict definition at the settings, and it + is used in some of the microsite_configuration methods. + - The site field is django site. + - The values field must be validated on save to prevent the platform from crashing + badly in the case the string is not able to be loaded as json. + """ + site = models.OneToOneField(Site, related_name='microsite') + key = models.CharField(max_length=63, db_index=True, unique=True) + values = JSONField(null=False, blank=True, load_kwargs={'object_pairs_hook': collections.OrderedDict}) + + def __unicode__(self): + return self.key + + def get_organizations(self): + """ + Helper method to return a list of organizations associated with our particular Microsite + """ + return MicrositeOrganizationMapping.get_organizations_for_microsite_by_pk(self.id) # pylint: disable=no-member + + @classmethod + def get_microsite_for_domain(cls, domain): + """ + Returns the microsite associated with this domain. Note that we always convert to lowercase, or + None if no match + """ + + # remove any port number from the hostname + domain = domain.split(':')[0] + microsites = cls.objects.filter(site__domain__iexact=domain) + + return microsites[0] if microsites else None + + +class MicrositeHistory(TimeStampedModel): + """ + This is an archive table for Microsites model, so that we can maintain a history of changes. Note that the + key field is no longer unique + """ + site = models.OneToOneField(Site, related_name='microsite_history') + key = models.CharField(max_length=63, db_index=True, unique=True) + values = JSONField(null=False, blank=True, load_kwargs={'object_pairs_hook': collections.OrderedDict}) + + def __unicode__(self): + return self.key + + class Meta(object): + """ Meta class for this Django model """ + verbose_name_plural = "Microsite histories" + + +def _make_archive_copy(instance): + """ + Helper method to make a copy of a Microsite into the history table + """ + archive_object = MicrositeHistory( + key=instance.key, + site=instance.site, + values=instance.values, + ) + archive_object.save() + + +@receiver(pre_delete, sender=Microsite) +def on_microsite_deleted(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Archive the exam attempt when the item is about to be deleted + Make a clone and populate in the History table + """ + _make_archive_copy(instance) + + +@receiver(pre_save, sender=Microsite) +def on_microsite_updated(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Archive the microsite on an update operation + """ + + if instance.id: + # on an update case, get the original and archive it + original = Microsite.objects.get(id=instance.id) + _make_archive_copy(original) + + +class MicrositeOrganizationMapping(models.Model): + """ + Mapping of Organization to which Microsite it belongs + """ + + organization = models.CharField(max_length=63, db_index=True, unique=True) + microsite = models.ForeignKey(Microsite, db_index=True) + + # for archiving + history = HistoricalRecords() + + def __unicode__(self): + """String conversion""" + return u'{microsite_key}: {organization}'.format( + microsite_key=self.microsite.key, + organization=self.organization + ) + + @classmethod + def get_organizations_for_microsite_by_pk(cls, microsite_pk): + """ + Returns a list of organizations associated with the microsite key, returned as a set + """ + return cls.objects.filter(microsite_id=microsite_pk).values_list('organization', flat=True) + + @classmethod + def get_microsite_for_organization(cls, org): + """ + Returns the microsite object for a given organization based on the table mapping, None if + no mapping exists + """ + + try: + item = cls.objects.select_related('microsite').get(organization=org) + return item.microsite + except ObjectDoesNotExist: + return None + + +class MicrositeTemplate(models.Model): + """ + A HTML template that a microsite can use + """ + + microsite = models.ForeignKey(Microsite, db_index=True) + template_uri = models.CharField(max_length=255, db_index=True) + template = models.TextField() + + # for archiving + history = HistoricalRecords() + + def __unicode__(self): + """String conversion""" + return u'{microsite_key}: {template_uri}'.format( + microsite_key=self.microsite.key, + template_uri=self.template_uri + ) + + class Meta(object): + """ Meta class for this Django model """ + unique_together = (('microsite', 'template_uri'),) + + @classmethod + def get_template_for_microsite(cls, domain, template_uri): + """ + Returns the template object for the microsite, None if not found + """ + try: + return cls.objects.get(microsite__site__domain=domain, template_uri=template_uri) + except ObjectDoesNotExist: + return None diff --git a/common/djangoapps/microsite_configuration/tests/backends/__init__.py b/common/djangoapps/microsite_configuration/tests/backends/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/djangoapps/microsite_configuration/tests/backends/test_base.py b/common/djangoapps/microsite_configuration/tests/backends/test_base.py new file mode 100644 index 000000000000..f4c88cb7fd3c --- /dev/null +++ b/common/djangoapps/microsite_configuration/tests/backends/test_base.py @@ -0,0 +1,132 @@ +""" +Test Microsite base backends. +""" +from django.test import TestCase + +from microsite_configuration.backends.base import ( + AbstractBaseMicrositeBackend, +) + + +class NullBackend(AbstractBaseMicrositeBackend): + """ + A class that does nothing but inherit from the base class. + We created this class to test methods of AbstractBaseMicrositeBackend class. + Since abstract class cannot be instantiated we created this wrapper class. + """ + def set_config_by_domain(self, domain): + """ + For a given request domain, find a match in our microsite configuration + and make it available to the complete django request process + """ + return super(NullBackend, self).set_config_by_domain(domain) + + def get_template_path(self, relative_path, **kwargs): + """ + Returns a path (string) to a Mako template, which can either be in + an override or will just return what is passed in which is expected to be a string + """ + return super(NullBackend, self).get_template_path(relative_path, **kwargs) + + def get_value(self, val_name, default=None, **kwargs): + """ + Returns a value associated with the request's microsite, if present + """ + return super(NullBackend, self).get_value(val_name, default, **kwargs) + + def get_dict(self, dict_name, default=None, **kwargs): + """ + Returns a dictionary product of merging the request's microsite and + the default value. + This can be used, for example, to return a merged dictonary from the + settings.FEATURES dict, including values defined at the microsite + """ + return super(NullBackend, self).get_dict(dict_name, default, **kwargs) + + def is_request_in_microsite(self): + """ + This will return True/False if the current request is a request within a microsite + """ + return super(NullBackend, self).is_request_in_microsite() + + def has_override_value(self, val_name): + """ + Returns True/False whether a Microsite has a definition for the + specified named value + """ + return super(NullBackend, self).has_override_value(val_name) + + def get_all_config(self): + """ + This returns a set of orgs that are considered within all microsites. + This can be used, for example, to do filtering + """ + return super(NullBackend, self).get_all_config() + + def get_value_for_org(self, org, val_name, default=None): + """ + This returns a configuration value for a microsite which has an org_filter that matches + what is passed in + """ + return super(NullBackend, self).get_value_for_org(org, val_name, default) + + def get_all_orgs(self): + """ + This returns a set of orgs that are considered within a microsite. This can be used, + for example, to do filtering + """ + return super(NullBackend, self).get_all_orgs() + + def clear(self): + """ + Clears out any microsite configuration from the current request/thread + """ + return super(NullBackend, self).clear() + + +class AbstractBaseMicrositeBackendTests(TestCase): + """ + Go through and test the base abstract class + """ + + def test_cant_create_instance(self): + """ + We shouldn't be able to create an instance of the base abstract class + """ + + with self.assertRaises(TypeError): + AbstractBaseMicrositeBackend() # pylint: disable=abstract-class-instantiated + + def test_not_yet_implemented(self): + """ + Make sure all base methods raise a NotImplementedError exception + """ + + backend = NullBackend() + + with self.assertRaises(NotImplementedError): + backend.set_config_by_domain(None) + + with self.assertRaises(NotImplementedError): + backend.get_value(None, None) + + with self.assertRaises(NotImplementedError): + backend.get_dict(None, None) + + with self.assertRaises(NotImplementedError): + backend.is_request_in_microsite() + + with self.assertRaises(NotImplementedError): + backend.has_override_value(None) + + with self.assertRaises(NotImplementedError): + backend.get_all_config() + + with self.assertRaises(NotImplementedError): + backend.clear() + + with self.assertRaises(NotImplementedError): + backend.get_value_for_org(None, None, None) + + with self.assertRaises(NotImplementedError): + backend.get_all_orgs() diff --git a/common/djangoapps/microsite_configuration/tests/backends/test_database.py b/common/djangoapps/microsite_configuration/tests/backends/test_database.py new file mode 100644 index 000000000000..294da02ece66 --- /dev/null +++ b/common/djangoapps/microsite_configuration/tests/backends/test_database.py @@ -0,0 +1,220 @@ +""" +Test Microsite database backends. +""" +import logging +from mock import patch + +from django.conf import settings + +from microsite_configuration.backends.base import ( + BaseMicrositeBackend, + BaseMicrositeTemplateBackend, +) +from microsite_configuration import microsite +from microsite_configuration.models import ( + Microsite, + MicrositeHistory, + MicrositeTemplate, +) +from microsite_configuration.tests.tests import ( + DatabaseMicrositeTestCase, +) +from microsite_configuration.tests.factories import ( + SiteFactory, + MicrositeFactory, + MicrositeTemplateFactory, +) + +log = logging.getLogger(__name__) + + +@patch( + 'microsite_configuration.microsite.BACKEND', + microsite.get_backend( + 'microsite_configuration.backends.database.DatabaseMicrositeBackend', BaseMicrositeBackend + ) +) +class DatabaseMicrositeBackendTests(DatabaseMicrositeTestCase): + """ + Go through and test the DatabaseMicrositeBackend class + """ + def setUp(self): + super(DatabaseMicrositeBackendTests, self).setUp() + + def tearDown(self): + super(DatabaseMicrositeBackendTests, self).tearDown() + microsite.clear() + + def test_get_value(self): + """ + Tests microsite.get_value works as expected. + """ + microsite.set_by_domain(self.microsite.site.domain) + self.assertEqual(microsite.get_value('email_from_address'), self.microsite.values['email_from_address']) + + def test_is_request_in_microsite(self): + """ + Tests microsite.is_request_in_microsite works as expected. + """ + microsite.set_by_domain(self.microsite.site.domain) + self.assertTrue(microsite.is_request_in_microsite()) + + def test_get_dict(self): + """ + Tests microsite.get_dict works as expected. + """ + microsite.set_by_domain(self.microsite.site.domain) + self.assertEqual(microsite.get_dict('nested_dict'), self.microsite.values['nested_dict']) + + def test_has_override_value(self): + """ + Tests microsite.has_override_value works as expected. + """ + microsite.set_by_domain(self.microsite.site.domain) + self.assertTrue(microsite.has_override_value('platform_name')) + + def test_get_value_for_org(self): + """ + Tests microsite.get_value_for_org works as expected. + """ + microsite.set_by_domain(self.microsite.site.domain) + self.assertEqual( + microsite.get_value_for_org(self.microsite.get_organizations()[0], 'platform_name'), + self.microsite.values['platform_name'] + ) + + def test_get_all_orgs(self): + """ + Tests microsite.get_all_orgs works as expected. + """ + microsite.set_by_domain(self.microsite.site.domain) + self.assertEqual( + microsite.get_all_orgs(), + set(self.microsite.get_organizations()) + ) + + def test_clear(self): + """ + Tests microsite.clear works as expected. + """ + microsite.set_by_domain(self.microsite.site.domain) + self.assertEqual( + microsite.get_value('platform_name'), + self.microsite.values['platform_name'] + ) + 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']) + + @patch('edxmako.paths.add_lookup') + def test_enable_microsites(self, add_lookup): + """ + Tests microsite.enable_microsites works as expected. + """ + # remove microsite root directory paths first + settings.STATICFILES_DIRS = [ + path for path in settings.STATICFILES_DIRS + if path != settings.MICROSITE_ROOT_DIR + ] + with patch.dict('django.conf.settings.FEATURES', {'USE_MICROSITES': False}): + microsite.enable_microsites(log) + self.assertNotIn(settings.MICROSITE_ROOT_DIR, settings.STATICFILES_DIRS) + add_lookup.assert_not_called() + 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): + """ + Tests microsite.get_all_config works as expected. + """ + microsite.set_by_domain(self.microsite.site.domain) + configs = microsite.get_all_config() + self.assertEqual(len(configs.keys()), 1) + self.assertEqual(configs[self.microsite.key], self.microsite.values) + + def test_set_config_by_domain(self): + """ + Tests microsite.set_config_by_domain works as expected. + """ + microsite.clear() + # if microsite config does not exist + microsite.set_by_domain('unknown') + self.assertIsNone(microsite.get_value('platform_name')) + + # if no microsite exists + Microsite.objects.all().delete() + microsite.clear() + microsite.set_by_domain('unknown') + self.assertIsNone(microsite.get_value('platform_name')) + + # if microsite site has no organization it should raise exception + new_microsite = MicrositeFactory.create(key="test_microsite2") + new_microsite.site = SiteFactory.create(domain='test.microsite2.com') + # This would update microsite so we test MicrositeHistory has old microsite + new_microsite.save() + self.assertEqual(MicrositeHistory.objects.all().count(), 2) + with self.assertRaises(Exception): + microsite.set_by_domain('test.microsite2.com') + + +@patch( + 'microsite_configuration.microsite.TEMPLATES_BACKEND', + microsite.get_backend( + 'microsite_configuration.backends.database.DatabaseMicrositeTemplateBackend', BaseMicrositeTemplateBackend + ) +) +class DatabaseMicrositeTemplateBackendTests(DatabaseMicrositeTestCase): + """ + Go through and test the DatabaseMicrositeTemplateBackend class + """ + def setUp(self): + super(DatabaseMicrositeTemplateBackendTests, self).setUp() + MicrositeTemplateFactory.create( + microsite=self.microsite, + template_uri='about.html', + template=""" + + + About this microsite. + + + """, + ) + + def tearDown(self): + super(DatabaseMicrositeTemplateBackendTests, self).tearDown() + microsite.clear() + + def test_microsite_get_template_when_no_template_exists(self): + """ + Test microsite.get_template return None if there is not template in DB. + """ + MicrositeTemplate.objects.all().delete() + microsite.set_by_domain(self.microsite.site.domain) + template = microsite.get_template('about.html') + self.assertIsNone(template) + + def test_microsite_get_template(self): + """ + Test microsite.get_template return appropriate template. + """ + microsite.set_by_domain(self.microsite.site.domain) + template = microsite.get_template('about.html') + self.assertIn('About this microsite', template.render()) diff --git a/common/djangoapps/microsite_configuration/tests/backends/test_filebased.py b/common/djangoapps/microsite_configuration/tests/backends/test_filebased.py new file mode 100644 index 000000000000..99a103d42ce8 --- /dev/null +++ b/common/djangoapps/microsite_configuration/tests/backends/test_filebased.py @@ -0,0 +1,116 @@ +""" +Test Microsite filebased backends. +""" +from mock import patch + +from django.test import TestCase + +from microsite_configuration.backends.base import ( + BaseMicrositeBackend, +) +from microsite_configuration import microsite + + +@patch( + 'microsite_configuration.microsite.BACKEND', + microsite.get_backend( + 'microsite_configuration.backends.filebased.FilebasedMicrositeBackend', BaseMicrositeBackend + ) +) +class FilebasedMicrositeBackendTests(TestCase): + """ + Go through and test the FilebasedMicrositeBackend class + """ + def setUp(self): + super(FilebasedMicrositeBackendTests, self).setUp() + self.microsite_subdomain = 'testmicrosite' + + def tearDown(self): + super(FilebasedMicrositeBackendTests, self).tearDown() + microsite.clear() + + def test_get_value(self): + """ + Tests microsite.get_value works as expected. + """ + microsite.set_by_domain(self.microsite_subdomain) + self.assertEqual(microsite.get_value('platform_name'), 'Test Microsite') + + def test_is_request_in_microsite(self): + """ + Tests microsite.is_request_in_microsite works as expected. + """ + microsite.set_by_domain(self.microsite_subdomain) + self.assertTrue(microsite.is_request_in_microsite()) + + def test_has_override_value(self): + """ + Tests microsite.has_override_value works as expected. + """ + microsite.set_by_domain(self.microsite_subdomain) + self.assertTrue(microsite.has_override_value('platform_name')) + + def test_get_value_for_org(self): + """ + Tests microsite.get_value_for_org works as expected. + """ + microsite.set_by_domain(self.microsite_subdomain) + self.assertEqual( + microsite.get_value_for_org('TestMicrositeX', 'platform_name'), + 'Test Microsite' + ) + + # if no config is set + microsite.clear() + with patch('django.conf.settings.MICROSITE_CONFIGURATION', False): + self.assertEqual( + microsite.get_value_for_org('TestMicrositeX', 'platform_name', 'Default Value'), + 'Default Value' + ) + + def test_get_all_orgs(self): + """ + Tests microsite.get_all_orgs works as expected. + """ + microsite.set_by_domain(self.microsite_subdomain) + self.assertEqual( + microsite.get_all_orgs(), + set(['TestMicrositeX', 'LogistrationX']) + ) + + # if no config is set + microsite.clear() + with patch('django.conf.settings.MICROSITE_CONFIGURATION', False): + self.assertEqual( + microsite.get_all_orgs(), + set() + ) + + def test_clear(self): + """ + Tests microsite.clear works as expected. + """ + microsite.set_by_domain(self.microsite_subdomain) + self.assertEqual( + microsite.get_value('platform_name'), + 'Test Microsite' + ) + microsite.clear() + self.assertIsNone(microsite.get_value('platform_name')) + + def test_get_all_configs(self): + """ + Tests microsite.get_all_config works as expected. + """ + microsite.set_by_domain(self.microsite_subdomain) + configs = microsite.get_all_config() + self.assertEqual(len(configs.keys()), 3) + + def test_set_config_by_domain(self): + """ + Tests microsite.set_config_by_domain works as expected. + """ + microsite.clear() + # if microsite config does not exist default config should be used + microsite.set_by_domain('unknown') + self.assertEqual(microsite.get_value('university'), 'default_university') diff --git a/common/djangoapps/microsite_configuration/tests/factories.py b/common/djangoapps/microsite_configuration/tests/factories.py new file mode 100644 index 000000000000..6594abfc7cbf --- /dev/null +++ b/common/djangoapps/microsite_configuration/tests/factories.py @@ -0,0 +1,79 @@ +""" +Factories module to hold microsite factories +""" +import factory +from factory.django import DjangoModelFactory + +from django.contrib.sites.models import Site + +from microsite_configuration.models import ( + Microsite, + MicrositeOrganizationMapping, + MicrositeTemplate, +) + + +class SiteFactory(DjangoModelFactory): + """ + Factory for django.contrib.sites.models.Site + """ + class Meta(object): + model = Site + + name = "test microsite" + domain = "testmicrosite.testserver" + + +class MicrositeFactory(DjangoModelFactory): + """ + Factory for Microsite + """ + class Meta(object): + model = Microsite + + key = "test_microsite" + site = factory.SubFactory(SiteFactory) + values = { + "domain_prefix": "testmicrosite", + "university": "test_microsite", + "platform_name": "Test Microsite DB", + "logo_image_url": "test_microsite/images/header-logo.png", + "email_from_address": "test_microsite_db@edx.org", + "payment_support_email": "test_microsit_dbe@edx.org", + "ENABLE_MKTG_SITE": False, + "SITE_NAME": "test_microsite.localhost", + "course_org_filter": "TestMicrositeX", + "course_about_show_social_links": False, + "css_overrides_file": "test_microsite/css/test_microsite.css", + "show_partners": False, + "show_homepage_promo_video": False, + "course_index_overlay_text": "This is a Test Microsite Overlay Text.", + "course_index_overlay_logo_file": "test_microsite/images/header-logo.png", + "homepage_overlay_html": "

This is a Test Microsite Overlay HTML

", + "ALWAYS_REDIRECT_HOMEPAGE_TO_DASHBOARD_FOR_AUTHENTICATED_USER": False, + "COURSE_CATALOG_VISIBILITY_PERMISSION": "see_in_catalog", + "COURSE_ABOUT_VISIBILITY_PERMISSION": "see_about_page", + "ENABLE_SHOPPING_CART": True, + "ENABLE_PAID_COURSE_REGISTRATION": True, + "SESSION_COOKIE_DOMAIN": "test_microsite.localhost", + "nested_dict": { + "key 1": "value 1", + "key 2": "value 2", + } + } + + +class MicrositeOrganizationMappingFactory(DjangoModelFactory): + """ + Factory for MicrositeOrganizationMapping + """ + class Meta(object): + model = MicrositeOrganizationMapping + + +class MicrositeTemplateFactory(DjangoModelFactory): + """ + Factory for MicrositeTemplate + """ + class Meta(object): + model = MicrositeTemplate diff --git a/common/djangoapps/microsite_configuration/tests/test_logic.py b/common/djangoapps/microsite_configuration/tests/test_logic.py index 2d0ad93fcb9d..26e22f546bb7 100644 --- a/common/djangoapps/microsite_configuration/tests/test_logic.py +++ b/common/djangoapps/microsite_configuration/tests/test_logic.py @@ -2,25 +2,45 @@ Some additional unit tests for Microsite logic. The LMS covers some of the Microsite testing, this adds some additional coverage """ -import django.test +import ddt +from mock import patch -from microsite_configuration.microsite import get_value_for_org +from microsite_configuration.microsite import ( + get_value_for_org, + get_backend, +) +from microsite_configuration.backends.base import BaseMicrositeBackend +from microsite_configuration.tests.tests import ( + DatabaseMicrositeTestCase, + MICROSITE_BACKENDS, +) -class TestMicrosites(django.test.TestCase): +@ddt.ddt +class TestMicrosites(DatabaseMicrositeTestCase): """ Run through some Microsite logic """ - def test_get_value_for_org(self): + def setUp(self): + super(TestMicrosites, self).setUp() + + @ddt.data(*MICROSITE_BACKENDS) + def test_get_value_for_org_when_microsite_has_no_org(self, site_backend): """ - Make sure we can do lookups on Microsite configuration based on ORG fields + Make sure default value is returned if there's no Microsite ORG match """ + with patch('microsite_configuration.microsite.BACKEND', + get_backend(site_backend, BaseMicrositeBackend)): + value = get_value_for_org("BogusX", "university", "default_value") + self.assertEquals(value, "default_value") - # first make sure default value is returned if there's no Microsite ORG match - value = get_value_for_org("BogusX", "university", "default_value") - self.assertEquals(value, "default_value") - - # now test when we call in a value Microsite ORG, note this is defined in test.py configuration - value = get_value_for_org("TestMicrositeX", "university", "default_value") - self.assertEquals(value, "test_microsite") + @ddt.data(*MICROSITE_BACKENDS) + def test_get_value_for_org(self, site_backend): + """ + Make sure get_value_for_org return value of org if it present. + """ + with patch('microsite_configuration.microsite.BACKEND', + get_backend(site_backend, BaseMicrositeBackend)): + value = get_value_for_org("TestMicrositeX", "university", "default_value") + self.assertEquals(value, "test_microsite") diff --git a/common/djangoapps/microsite_configuration/tests/test_microsites.py b/common/djangoapps/microsite_configuration/tests/test_microsites.py index 74b4f849d4e6..e8982b414f72 100644 --- a/common/djangoapps/microsite_configuration/tests/test_microsites.py +++ b/common/djangoapps/microsite_configuration/tests/test_microsites.py @@ -4,38 +4,73 @@ """ from django.test import TestCase from django.conf import settings -from microsite_configuration.templatetags import microsite +from microsite_configuration.templatetags import microsite as microsite_tags +from microsite_configuration import microsite +from microsite_configuration.backends.base import BaseMicrositeBackend +from microsite_configuration.backends.database import DatabaseMicrositeBackend -class MicroSiteTests(TestCase): +class MicrositeTests(TestCase): """ Make sure some of the helper functions work """ def test_breadcrumbs(self): crumbs = ['my', 'less specific', 'Page'] expected = u'my | less specific | Page | edX' - title = microsite.page_title_breadcrumbs(*crumbs) + title = microsite_tags.page_title_breadcrumbs(*crumbs) self.assertEqual(expected, title) def test_unicode_title(self): crumbs = [u'øo', u'π tastes gréât', u'驴'] expected = u'øo | π tastes gréât | 驴 | edX' - title = microsite.page_title_breadcrumbs(*crumbs) + title = microsite_tags.page_title_breadcrumbs(*crumbs) self.assertEqual(expected, title) def test_platform_name(self): - pname = microsite.platform_name() + pname = microsite_tags.platform_name() self.assertEqual(pname, settings.PLATFORM_NAME) def test_breadcrumb_tag(self): crumbs = ['my', 'less specific', 'Page'] expected = u'my | less specific | Page | edX' - title = microsite.page_title_breadcrumbs_tag(None, *crumbs) + title = microsite_tags.page_title_breadcrumbs_tag(None, *crumbs) self.assertEqual(expected, title) def test_microsite_template_path(self): """ When an unexistent path is passed to the filter, it should return the same path """ - path = microsite.microsite_template_path('footer.html') + path = microsite_tags.microsite_template_path('footer.html') self.assertEqual("footer.html", path) + + def test_get_backend_raise_error_for_invalid_class(self): + """ + Test get_backend returns None for invalid paths + and raises TypeError when invalid class or class name is a method. + """ + # invalid backend path + self.assertEqual(microsite.get_backend(None, BaseMicrositeBackend), None) + + # invalid class or class name is a method + with self.assertRaises(TypeError): + microsite.get_backend('microsite_configuration.microsite.get_backend', BaseMicrositeBackend) + + def test_get_backend_raise_error_when_module_has_no_class(self): + """ + Test get_backend raises ValueError when module does not have a class. + """ + # module does not have a class + with self.assertRaises(ValueError): + microsite.get_backend('microsite_configuration.microsite.invalid_method', BaseMicrositeBackend) + + def test_get_backend_for_valid_class(self): + """ + Test get_backend loads class if class exists. + """ + # load a valid class + self.assertIsInstance( + microsite.get_backend( + 'microsite_configuration.backends.database.DatabaseMicrositeBackend', BaseMicrositeBackend + ), + DatabaseMicrositeBackend + ) diff --git a/common/djangoapps/microsite_configuration/tests/test_middleware.py b/common/djangoapps/microsite_configuration/tests/test_middleware.py index c68636dcc82f..47d6cd20eaf4 100644 --- a/common/djangoapps/microsite_configuration/tests/test_middleware.py +++ b/common/djangoapps/microsite_configuration/tests/test_middleware.py @@ -2,28 +2,39 @@ """ Test Microsite middleware. """ +import ddt +import unittest from mock import patch -from django.test import TestCase from django.conf import settings from django.test.client import Client from django.test.utils import override_settings -import unittest from student.tests.factories import UserFactory +from microsite_configuration.microsite import ( + get_backend, +) +from microsite_configuration.backends.base import BaseMicrositeBackend +from microsite_configuration.tests.tests import ( + DatabaseMicrositeTestCase, + side_effect_for_get_value, + MICROSITE_BACKENDS, +) # NOTE: We set SESSION_SAVE_EVERY_REQUEST to True in order to make sure # Sessions are always started on every request +# pylint: disable=no-member, protected-access +@ddt.ddt @override_settings(SESSION_SAVE_EVERY_REQUEST=True) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class MicroSiteSessionCookieTests(TestCase): +class MicrositeSessionCookieTests(DatabaseMicrositeTestCase): """ - Tests regarding the session cookie management in the middlware for MicroSites + Tests regarding the session cookie management in the middlware for Microsites """ def setUp(self): - super(MicroSiteSessionCookieTests, self).setUp() + super(MicrositeSessionCookieTests, self).setUp() # Create a test client, and log it in so that it will save some session # data. self.user = UserFactory.create() @@ -32,29 +43,39 @@ def setUp(self): self.client = Client() self.client.login(username=self.user.username, password="password") - def test_session_cookie_domain_no_microsite(self): + @ddt.data(*MICROSITE_BACKENDS) + def test_session_cookie_domain_no_microsite(self, site_backend): """ Tests that non-microsite behaves according to default behavior """ - response = self.client.get('/') - self.assertNotIn('test_microsite.localhost', str(response.cookies['sessionid'])) # pylint: disable=no-member - self.assertNotIn('Domain', str(response.cookies['sessionid'])) # pylint: disable=no-member + with patch('microsite_configuration.microsite.BACKEND', + get_backend(site_backend, BaseMicrositeBackend)): + response = self.client.get('/') + self.assertNotIn('test_microsite.localhost', str(response.cookies['sessionid'])) + self.assertNotIn('Domain', str(response.cookies['sessionid'])) - def test_session_cookie_domain(self): + @ddt.data(*MICROSITE_BACKENDS) + def test_session_cookie_domain(self, site_backend): """ Makes sure that the cookie being set in a Microsite - is the one specially overridden in configuration, - in this case in test.py + is the one specially overridden in configuration """ - response = self.client.get('/', HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME) - self.assertIn('test_microsite.localhost', str(response.cookies['sessionid'])) # pylint: disable=no-member + with patch('microsite_configuration.microsite.BACKEND', + get_backend(site_backend, BaseMicrositeBackend)): + response = self.client.get('/', HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME) + self.assertIn('test_microsite.localhost', str(response.cookies['sessionid'])) - @patch.dict("django.conf.settings.MICROSITE_CONFIGURATION", {'test_microsite': {'SESSION_COOKIE_DOMAIN': None}}) - def test_microsite_none_cookie_domain(self): + @ddt.data(*MICROSITE_BACKENDS) + def test_microsite_none_cookie_domain(self, site_backend): """ Tests to make sure that a Microsite that specifies None for 'SESSION_COOKIE_DOMAIN' does not set a domain on the session cookie """ - response = self.client.get('/', HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME) - self.assertNotIn('test_microsite.localhost', str(response.cookies['sessionid'])) # pylint: disable=no-member - self.assertNotIn('Domain', str(response.cookies['sessionid'])) # pylint: disable=no-member + + with patch('microsite_configuration.microsite.get_value') as mock_get_value: + mock_get_value.side_effect = side_effect_for_get_value('SESSION_COOKIE_DOMAIN', None) + with patch('microsite_configuration.microsite.BACKEND', + get_backend(site_backend, BaseMicrositeBackend)): + response = self.client.get('/', HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME) + self.assertNotIn('test_microsite.localhost', str(response.cookies['sessionid'])) + self.assertNotIn('Domain', str(response.cookies['sessionid'])) diff --git a/common/djangoapps/microsite_configuration/tests/tests.py b/common/djangoapps/microsite_configuration/tests/tests.py new file mode 100644 index 000000000000..616fe2ee3e52 --- /dev/null +++ b/common/djangoapps/microsite_configuration/tests/tests.py @@ -0,0 +1,41 @@ +""" +Holds base classes for microsite tests +""" +from mock import DEFAULT + +from django.test import TestCase +from microsite_configuration.tests.factories import ( + MicrositeFactory, + MicrositeOrganizationMappingFactory, +) + +MICROSITE_BACKENDS = ( + 'microsite_configuration.backends.filebased.FilebasedMicrositeBackend', + 'microsite_configuration.backends.database.DatabaseMicrositeBackend', +) + + +class DatabaseMicrositeTestCase(TestCase): + """ + Base class for microsite related tests. + """ + def setUp(self): + super(DatabaseMicrositeTestCase, self).setUp() + self.microsite = MicrositeFactory.create() + MicrositeOrganizationMappingFactory.create(microsite=self.microsite, organization='TestMicrositeX') + + +def side_effect_for_get_value(value, return_value): + """ + returns a side_effect with given return value for a given value + """ + def side_effect(*args, **kwargs): # pylint: disable=unused-argument + """ + A side effect for tests which returns a value based + on a given argument otherwise return actual function. + """ + if args[0] == value: + return return_value + else: + return DEFAULT + return side_effect diff --git a/common/djangoapps/util/url.py b/common/djangoapps/util/url.py index a5deda2b4e41..ffe20e4885c3 100644 --- a/common/djangoapps/util/url.py +++ b/common/djangoapps/util/url.py @@ -20,3 +20,10 @@ def reload_django_url_config(): reloaded = import_module(urlconf) reloaded_urls = reloaded.urlpatterns set_urlconf(tuple(reloaded_urls)) + + +def strip_port_from_host(host): + """ + Strips port number from host + """ + return host.split(':')[0] diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 192163012bcc..3510173ca851 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -537,9 +537,6 @@ MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED", 5) MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", 15 * 60) -MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {}) -MICROSITE_ROOT_DIR = path(ENV_TOKENS.get('MICROSITE_ROOT_DIR', '')) - #### PASSWORD POLICY SETTINGS ##### PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH") PASSWORD_MAX_LENGTH = ENV_TOKENS.get("PASSWORD_MAX_LENGTH") @@ -735,5 +732,17 @@ PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER) PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) +################# MICROSITE #################### +MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {}) +MICROSITE_ROOT_DIR = path(ENV_TOKENS.get('MICROSITE_ROOT_DIR', '')) +# this setting specify which backend to be used when pulling microsite specific configuration +MICROSITE_BACKEND = ENV_TOKENS.get("MICROSITE_BACKEND", MICROSITE_BACKEND) +# this setting specify which backend to be used when loading microsite specific templates +MICROSITE_TEMPLATE_BACKEND = ENV_TOKENS.get("MICROSITE_TEMPLATE_BACKEND", MICROSITE_TEMPLATE_BACKEND) +# TTL for microsite database template cache +MICROSITE_DATABASE_TEMPLATE_CACHE_TTL = ENV_TOKENS.get( + "MICROSITE_DATABASE_TEMPLATE_CACHE_TTL", MICROSITE_DATABASE_TEMPLATE_CACHE_TTL +) + # Course Content Bookmarks Settings MAX_BOOKMARKS_PER_COURSE = ENV_TOKENS.get('MAX_BOOKMARKS_PER_COURSE', MAX_BOOKMARKS_PER_COURSE) diff --git a/lms/envs/common.py b/lms/envs/common.py index 088779a71e4e..80078123a831 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2638,6 +2638,22 @@ NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css" NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png" + +################################ Settings for Microsites ################################ + +### Select an implementation for the microsite backend +# for MICROSITE_BACKEND possible choices are +# 1. microsite_configuration.backends.filebased.FilebasedMicrositeBackend +# 2. microsite_configuration.backends.database.DatabaseMicrositeBackend +MICROSITE_BACKEND = 'microsite_configuration.backends.filebased.FilebasedMicrositeBackend' +# for MICROSITE_TEMPLATE_BACKEND possible choices are +# 1. microsite_configuration.backends.filebased.FilebasedMicrositeTemplateBackend +# 2. microsite_configuration.backends.database.DatabaseMicrositeTemplateBackend +MICROSITE_TEMPLATE_BACKEND = 'microsite_configuration.backends.filebased.FilebasedMicrositeTemplateBackend' +# TTL for microsite database template cache +MICROSITE_DATABASE_TEMPLATE_CACHE_TTL = 5 * 60 + + #### PROCTORING CONFIGURATION DEFAULTS PROCTORING_BACKEND_PROVIDER = { diff --git a/lms/envs/test.py b/lms/envs/test.py index 9e7bb674864c..2b11f026a654 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -422,6 +422,8 @@ SITE_NAME = "edx.org" # set up some testing for microsites +FEATURES['USE_MICROSITES'] = True +MICROSITE_ROOT_DIR = COMMON_ROOT / 'test' / 'test_microsites' MICROSITE_CONFIGURATION = { "test_microsite": { "domain_prefix": "testmicrosite", @@ -482,15 +484,14 @@ "domain_prefix": "www", } } -MICROSITE_ROOT_DIR = COMMON_ROOT / 'test' / 'test_microsites' + MICROSITE_TEST_HOSTNAME = 'testmicrosite.testserver' MICROSITE_LOGISTRATION_HOSTNAME = 'logistration.testserver' -FEATURES['USE_MICROSITES'] = True - # add extra template directory for test-only templates MAKO_TEMPLATES['main'].extend([ - COMMON_ROOT / 'test' / 'templates' + COMMON_ROOT / 'test' / 'templates', + COMMON_ROOT / 'test' / 'test_microsites' ]) diff --git a/lms/startup.py b/lms/startup.py index bc14bb2e86fa..a80c7938f465 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -18,6 +18,8 @@ import xmodule.x_module import lms_xblock.runtime +from microsite_configuration import microsite + log = logging.getLogger(__name__) @@ -31,8 +33,10 @@ def run(): if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH', False): enable_third_party_auth() - if settings.FEATURES.get('USE_MICROSITES', False): - enable_microsites_pre_startup() + # We currently use 2 template rendering engines, mako and django_templates, + # and one of them (django templates), requires the directories be added + # before the django.setup(). + microsite.enable_microsites_pre_startup(log) django.setup() @@ -40,12 +44,12 @@ def run(): add_mimetypes() + # Mako requires the directories to be added after the django setup. + microsite.enable_microsites(log) + if settings.FEATURES.get('USE_CUSTOM_THEME', False): enable_stanford_theme() - if settings.FEATURES.get('USE_MICROSITES', False): - enable_microsites() - # Initialize Segment analytics module by setting the write_key. if settings.LMS_SEGMENT_KEY: analytics.write_key = settings.LMS_SEGMENT_KEY @@ -119,56 +123,12 @@ def enable_stanford_theme(): settings.LOCALE_PATHS = (theme_root / 'conf/locale',) + settings.LOCALE_PATHS -def enable_microsites_pre_startup(): - """ - The TEMPLATE_ENGINE directory to search for microsite templates - in non-mako templates must be loaded before the django startup - """ - microsites_root = settings.MICROSITE_ROOT_DIR - microsite_config_dict = settings.MICROSITE_CONFIGURATION - - if microsite_config_dict: - settings.DEFAULT_TEMPLATE_ENGINE['DIRS'].append(microsites_root) - - def enable_microsites(): """ - Enable the use of microsites, which are websites that allow - for subdomains for the edX platform, e.g. foo.edx.org + Calls the enable_microsites function in the microsite backend. + Here for backwards compatibility """ - - microsites_root = settings.MICROSITE_ROOT_DIR - microsite_config_dict = settings.MICROSITE_CONFIGURATION - - for ms_name, ms_config in microsite_config_dict.items(): - # Calculate the location of the microsite's files - ms_root = microsites_root / ms_name - ms_config = microsite_config_dict[ms_name] - - # pull in configuration information from each - # microsite root - - if ms_root.isdir(): - # store the path on disk for later use - ms_config['microsite_root'] = ms_root - - template_dir = ms_root / 'templates' - ms_config['template_dir'] = template_dir - - ms_config['microsite_name'] = ms_name - log.info('Loading microsite %s', ms_root) - else: - # not sure if we have application logging at this stage of - # startup - log.error('Error loading microsite %s. Directory does not exist', ms_root) - # remove from our configuration as it is not valid - del microsite_config_dict[ms_name] - - # if we have any valid microsites defined, let's wire in the Mako and STATIC_FILES search paths - if microsite_config_dict: - edxmako.paths.add_lookup('main', microsites_root) - - settings.STATICFILES_DIRS.insert(0, microsites_root) + microsite.enable_microsites(log) def enable_third_party_auth():