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():