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 4adee7c0d155..301fb0816ccc 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -2641,6 +2641,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 52583cd3cf44..2b58e02569cd 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -423,6 +423,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",
@@ -483,15 +485,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():