@@ -312,9 +312,6 @@ VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', [])
|
||||
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 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g
|
||||
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.
|
||||
|
||||
@@ -827,6 +827,10 @@ INSTALLED_APPS = (
|
||||
# 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 @@ DEPRECATED_BLOCK_TYPES = [
|
||||
'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 = {
|
||||
@@ -1142,3 +1161,6 @@ PROCTORING_SETTINGS = {}
|
||||
|
||||
# OpenID Connect issuer ID. Normally the URL of the authentication endpoint.
|
||||
OAUTH_OIDC_ISSUER = 'https://www.example.com/oauth2'
|
||||
|
||||
# 5 minute expiration time for JWT id tokens issued for external API requests.
|
||||
OAUTH_ID_TOKEN_EXPIRATION = 5 * 60
|
||||
|
||||
@@ -214,6 +214,8 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
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 @@ MICROSITE_CONFIGURATION = {
|
||||
"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": "<h1>This is a Test Microsite Overlay HTML</h1>"
|
||||
"homepage_overlay_html": "<h1>This is a Test Microsite Overlay HTML</h1>",
|
||||
"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": "<h1>This is a Logistration HTML</h1>",
|
||||
"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
|
||||
|
||||
|
Before Width: | Height: | Size: 117 B After Width: | Height: | Size: 73 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 954 B After Width: | Height: | Size: 88 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 302 B |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 633 B After Width: | Height: | Size: 333 B |
|
Before Width: | Height: | Size: 737 B After Width: | Height: | Size: 448 B |
|
Before Width: | Height: | Size: 581 B After Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 797 B After Width: | Height: | Size: 493 B |
|
Before Width: | Height: | Size: 234 B After Width: | Height: | Size: 110 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 434 B |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 267 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 846 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -11,13 +11,18 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='coursemode',
|
||||
name='expiration_datetime',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='coursemode',
|
||||
name='_expiration_datetime',
|
||||
field=models.DateTimeField(db_column=b'expiration_datetime', default=None, blank=True, help_text='OPTIONAL: After this date/time, users will no longer be able to enroll in this mode. Leave this blank if users can enroll in this mode until enrollment closes for the course.', null=True, verbose_name='Upgrade Deadline'),
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.RemoveField(
|
||||
model_name='coursemode',
|
||||
name='expiration_datetime',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='coursemode',
|
||||
name='_expiration_datetime',
|
||||
field=models.DateTimeField(db_column=b'expiration_datetime', default=None, blank=True, help_text='OPTIONAL: After this date/time, users will no longer be able to enroll in this mode. Leave this blank if users can enroll in this mode until enrollment closes for the course.', null=True, verbose_name='Upgrade Deadline'),
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ import pkg_resources
|
||||
from django.conf import settings
|
||||
from mako.lookup import TemplateLookup
|
||||
|
||||
from microsite_configuration import microsite
|
||||
from . import LOOKUP
|
||||
|
||||
|
||||
@@ -46,6 +47,18 @@ class DynamicTemplateLookup(TemplateLookup):
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -166,8 +166,5 @@ def render_to_response(template_name, dictionary=None, context_instance=None, na
|
||||
lookup.get_template(args[0]).render with the passed arguments.
|
||||
"""
|
||||
|
||||
# see if there is an override template defined in the microsite
|
||||
template_name = microsite.get_template_path(template_name)
|
||||
|
||||
dictionary = dictionary or {}
|
||||
return HttpResponse(render_to_string(template_name, dictionary, context_instance, namespace), **kwargs)
|
||||
|
||||
@@ -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
|
||||
|
||||
83
common/djangoapps/microsite_configuration/admin.py
Normal file
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
340
common/djangoapps/microsite_configuration/backends/base.py
Normal file
@@ -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
|
||||
211
common/djangoapps/microsite_configuration/backends/database.py
Normal file
@@ -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 = <html><body>Template from DB</body></html>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -6,45 +6,53 @@ A microsite enables the following features:
|
||||
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
|
||||
|
||||
CURRENT_REQUEST_CONFIGURATION = threading.local()
|
||||
CURRENT_REQUEST_CONFIGURATION.data = {}
|
||||
from microsite_configuration.backends.base import BaseMicrositeBackend, BaseMicrositeTemplateBackend
|
||||
|
||||
|
||||
def has_configuration_set():
|
||||
__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',
|
||||
]
|
||||
|
||||
BACKEND = None
|
||||
TEMPLATES_BACKEND = None
|
||||
|
||||
|
||||
def is_feature_enabled():
|
||||
"""
|
||||
Returns whether there is any Microsite configuration settings
|
||||
Returns whether the feature flag to enable microsite has been set
|
||||
"""
|
||||
return getattr(settings, "MICROSITE_CONFIGURATION", False)
|
||||
|
||||
|
||||
def get_configuration():
|
||||
"""
|
||||
Returns the current request's microsite configuration
|
||||
"""
|
||||
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 get_dict(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 BACKEND.get_dict(dict_name, default, **kwargs)
|
||||
|
||||
|
||||
def has_override_value(val_name):
|
||||
@@ -52,33 +60,7 @@ def has_override_value(val_name):
|
||||
Returns True/False whether a Microsite has a definition for the
|
||||
specified named value
|
||||
"""
|
||||
configuration = get_configuration()
|
||||
return val_name in configuration
|
||||
|
||||
|
||||
def get_template_path(relative_path):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
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 = {}
|
||||
|
||||
|
||||
def _set_current_microsite(microsite_config_key, subdomain, domain):
|
||||
"""
|
||||
Helper internal method to actually put a microsite on the threadlocal
|
||||
"""
|
||||
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.clear()
|
||||
|
||||
|
||||
def set_by_domain(domain):
|
||||
"""
|
||||
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
|
||||
For a given request domain, find a match in our microsite configuration
|
||||
and make it available to the complete django request process
|
||||
"""
|
||||
if not has_configuration_set() or not domain:
|
||||
BACKEND.set_config_by_domain(domain)
|
||||
|
||||
|
||||
def enable_microsites_pre_startup(log):
|
||||
"""
|
||||
Prepare the feature settings that must be enabled before django.setup() or
|
||||
autostartup() during the startup script
|
||||
"""
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
BACKEND = get_backend(settings.MICROSITE_BACKEND, BaseMicrositeBackend)
|
||||
TEMPLATES_BACKEND = get_backend(settings.MICROSITE_TEMPLATE_BACKEND, BaseMicrositeTemplateBackend)
|
||||
|
||||
@@ -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')]),
|
||||
),
|
||||
]
|
||||
181
common/djangoapps/microsite_configuration/models.py
Normal file
@@ -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
|
||||
@@ -63,3 +63,14 @@ def microsite_css_overrides_file():
|
||||
return "<link href='{}' rel='stylesheet' type='text/css'>".format(static(file_path))
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
@register.filter
|
||||
def microsite_template_path(template_name):
|
||||
"""
|
||||
Django template filter to apply template overriding to microsites.
|
||||
The django_templates loader does not support the leading slash, therefore
|
||||
it is stripped before returning.
|
||||
"""
|
||||
template_name = microsite.get_template_path(template_name)
|
||||
return template_name[1:] if template_name[0] == '/' else template_name
|
||||
|
||||
@@ -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()
|
||||
@@ -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="""
|
||||
<html>
|
||||
<body>
|
||||
About this microsite.
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
)
|
||||
|
||||
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())
|
||||
@@ -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')
|
||||
79
common/djangoapps/microsite_configuration/tests/factories.py
Normal file
@@ -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": "<h1>This is a Test Microsite Overlay HTML</h1>",
|
||||
"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
|
||||
@@ -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):
|
||||
"""
|
||||
Make sure we can do lookups on Microsite configuration based on ORG fields
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestMicrosites, self).setUp()
|
||||
|
||||
# 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")
|
||||
@ddt.data(*MICROSITE_BACKENDS)
|
||||
def test_get_value_for_org_when_microsite_has_no_org(self, site_backend):
|
||||
"""
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -4,31 +4,73 @@ Tests microsite_configuration templatetags and helper functions.
|
||||
"""
|
||||
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_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
|
||||
)
|
||||
|
||||
@@ -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 @@ class MicroSiteSessionCookieTests(TestCase):
|
||||
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']))
|
||||
|
||||
41
common/djangoapps/microsite_configuration/tests/tests.py
Normal file
@@ -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
|
||||
@@ -1,10 +1,24 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from opaque_keys import InvalidKeyError
|
||||
from optparse import make_option
|
||||
from student.models import CourseEnrollment, User
|
||||
""" Command line script to change user enrollments. """
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from optparse import make_option
|
||||
|
||||
from student.models import CourseEnrollment, User
|
||||
|
||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class RollbackException(Exception):
|
||||
"""
|
||||
Exception raised explicitly to cause a database transaction rollback.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -15,17 +29,17 @@ class Command(BaseCommand):
|
||||
|
||||
Example:
|
||||
|
||||
Change enrollment for user joe from audit to honor:
|
||||
Change enrollment for users joe, frank, and bill from audit to honor:
|
||||
|
||||
$ ... change_enrollment -u joe,frank,bill -c some/course/id --from audit --to honor
|
||||
|
||||
Or
|
||||
|
||||
$ ... change_enrollment -u "joe@example.com,frank@example.com,bill@example.com" -c some/course/id --from audit --to honor
|
||||
$ ... change_enrollment -e "joe@example.com,frank@example.com,bill@example.com" -c some/course/id --from audit --to honor
|
||||
|
||||
Change enrollment for all users in some/course/id from audit to honor
|
||||
See what would have been changed from audit to honor without making that change
|
||||
|
||||
$ ... change_enrollment -c some/course/id --from audit --to honor
|
||||
$ ... change_enrollment -u joe,frank,bill -c some/course/id --from audit --to honor -n
|
||||
|
||||
"""
|
||||
|
||||
@@ -40,11 +54,16 @@ class Command(BaseCommand):
|
||||
dest='to_mode',
|
||||
default=False,
|
||||
help='move to this enrollment mode'),
|
||||
make_option('-u', '--user',
|
||||
metavar='USER',
|
||||
dest='user',
|
||||
make_option('-u', '--usernames',
|
||||
metavar='USERNAME',
|
||||
dest='username',
|
||||
default=False,
|
||||
help="Comma-separated list of users to move in the course"),
|
||||
help="Comma-separated list of usernames to move in the course"),
|
||||
make_option('-e', '--emails',
|
||||
metavar='EMAIL',
|
||||
dest='email',
|
||||
default=False,
|
||||
help="Comma-separated list of email addresses to move in the course"),
|
||||
make_option('-c', '--course',
|
||||
metavar='COURSE_ID',
|
||||
dest='course_id',
|
||||
@@ -59,8 +78,11 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
error_users = []
|
||||
success_users = []
|
||||
|
||||
if not options['course_id']:
|
||||
raise CommandError("You must specify a course id for this command")
|
||||
raise CommandError('You must specify a course id for this command')
|
||||
if not options['from_mode'] or not options['to_mode']:
|
||||
raise CommandError('You must specify a "to" and "from" mode as parameters')
|
||||
|
||||
@@ -69,26 +91,55 @@ class Command(BaseCommand):
|
||||
except InvalidKeyError:
|
||||
course_key = SlashSeparatedCourseKey.from_deprecated_string(options['course_id'])
|
||||
|
||||
filter_args = dict(
|
||||
enrollment_args = dict(
|
||||
course_id=course_key,
|
||||
mode=options['from_mode']
|
||||
)
|
||||
if options['user']:
|
||||
user_str = options['user']
|
||||
for username in user_str.split(","):
|
||||
if '@' in username:
|
||||
user = User.objects.get(email=username)
|
||||
else:
|
||||
user = User.objects.get(username=username)
|
||||
filter_args['user'] = user
|
||||
enrollments = CourseEnrollment.objects.filter(**filter_args)
|
||||
if options['noop']:
|
||||
print "Would have changed {num_enrollments} students from {from_mode} to {to_mode}".format(
|
||||
num_enrollments=enrollments.count(),
|
||||
from_mode=options['from_mode'],
|
||||
to_mode=options['to_mode']
|
||||
)
|
||||
else:
|
||||
|
||||
if options['username']:
|
||||
self.update_enrollments('username', enrollment_args, options, error_users, success_users)
|
||||
|
||||
if options['email']:
|
||||
self.update_enrollments('email', enrollment_args, options, error_users, success_users)
|
||||
|
||||
self.report(error_users, success_users)
|
||||
|
||||
def update_enrollments(self, identifier, enrollment_args, options, error_users, success_users):
|
||||
""" Update enrollments for a specific user identifier (email or username). """
|
||||
users = options[identifier].split(",")
|
||||
for identified_user in users:
|
||||
logger.info(identified_user)
|
||||
try:
|
||||
user_args = {
|
||||
identifier: identified_user
|
||||
}
|
||||
|
||||
enrollment_args['user'] = User.objects.get(**user_args)
|
||||
enrollments = CourseEnrollment.objects.filter(**enrollment_args)
|
||||
|
||||
with transaction.atomic():
|
||||
for enrollment in enrollments:
|
||||
enrollment.update_enrollment(mode=options['to_mode'])
|
||||
enrollment.save()
|
||||
|
||||
if options['noop']:
|
||||
raise RollbackException('Forced rollback.')
|
||||
|
||||
except RollbackException:
|
||||
success_users.append(identified_user)
|
||||
continue
|
||||
except Exception as exception: # pylint: disable=broad-except
|
||||
error_users.append((identified_user, exception))
|
||||
continue
|
||||
|
||||
success_users.append(identified_user)
|
||||
logger.info('Updated user [%s] to mode [%s]', identified_user, options['to_mode'])
|
||||
|
||||
def report(self, error_users, success_users):
|
||||
""" Log and overview of the results of the command. """
|
||||
total_users = len(success_users) + len(error_users)
|
||||
logger.info('Successfully updated %i out of %i users', len(success_users), total_users)
|
||||
if len(error_users) > 0:
|
||||
logger.info('The following %i user(s) not saved:', len(error_users))
|
||||
for user, error in error_users:
|
||||
logger.info('user: [%s] reason: [%s] %s', user, type(error).__name__, error.message)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
""" Test the change_enrollment command line script."""
|
||||
|
||||
import ddt
|
||||
from mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
|
||||
from student.tests.factories import UserFactory, CourseModeFactory
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ChangeEnrollmentTests(SharedModuleStoreTestCase):
|
||||
""" Test the enrollment change functionality of the change_enrollment script."""
|
||||
def setUp(self):
|
||||
super(ChangeEnrollmentTests, self).setUp()
|
||||
self.course = CourseFactory.create()
|
||||
self.audit_mode = CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug='audit',
|
||||
mode_display_name='Audit',
|
||||
)
|
||||
self.honor_mode = CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug='honor',
|
||||
mode_display_name='Honor',
|
||||
)
|
||||
|
||||
self.user_info = [
|
||||
('amy', 'amy@pond.com', 'password'),
|
||||
('rory', 'rory@theroman.com', 'password'),
|
||||
('river', 'river@song.com', 'password')
|
||||
]
|
||||
self.enrollments = []
|
||||
self.users = []
|
||||
for username, email, password in self.user_info:
|
||||
user = UserFactory.create(username=username, email=email, password=password)
|
||||
self.users.append(user)
|
||||
self.enrollments.append(CourseEnrollment.enroll(user, self.course.id, mode='audit'))
|
||||
|
||||
@patch('student.management.commands.change_enrollment.logger')
|
||||
@ddt.data(
|
||||
('email', False, 3),
|
||||
('username', False, 3),
|
||||
('email', True, 0),
|
||||
('username', True, 0),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_convert_users(self, method, noop, expected_conversions, mock_logger):
|
||||
""" The command should update the user's enrollment. """
|
||||
user_str = ','.join([getattr(user, method) for user in self.users])
|
||||
user_ids = [u.id for u in self.users]
|
||||
command_args = {
|
||||
'course_id': unicode(self.course.id),
|
||||
'to_mode': 'honor',
|
||||
'from_mode': 'audit',
|
||||
'noop': noop,
|
||||
method: user_str,
|
||||
}
|
||||
|
||||
# Verify users are not in honor mode yet
|
||||
self.assertEqual(
|
||||
len(CourseEnrollment.objects.filter(mode='honor', user_id__in=user_ids)),
|
||||
0
|
||||
)
|
||||
|
||||
call_command(
|
||||
'change_enrollment',
|
||||
**command_args
|
||||
)
|
||||
|
||||
# Verify correct number of users are now in honor mode
|
||||
self.assertEqual(
|
||||
len(CourseEnrollment.objects.filter(mode='honor', user_id__in=user_ids)),
|
||||
expected_conversions
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_with(
|
||||
'Successfully updated %i out of %i users',
|
||||
len(self.users),
|
||||
len(self.users)
|
||||
)
|
||||
|
||||
@patch('student.management.commands.change_enrollment.logger')
|
||||
@ddt.data(
|
||||
('email', 'dtennant@thedoctor.com', 3),
|
||||
('username', 'dtennant', 3),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_user_not_found(self, method, fake_user, expected_success, mock_logger):
|
||||
all_users = [getattr(user, method) for user in self.users]
|
||||
all_users.append(fake_user)
|
||||
user_str = ','.join(all_users)
|
||||
real_user_ids = [u.id for u in self.users]
|
||||
command_args = {
|
||||
'course_id': unicode(self.course.id),
|
||||
'to_mode': 'honor',
|
||||
'from_mode': 'audit',
|
||||
method: user_str,
|
||||
}
|
||||
|
||||
# Verify users are not in honor mode yet
|
||||
self.assertEqual(
|
||||
len(CourseEnrollment.objects.filter(mode='honor', user_id__in=real_user_ids)),
|
||||
0
|
||||
)
|
||||
|
||||
call_command(
|
||||
'change_enrollment',
|
||||
**command_args
|
||||
)
|
||||
|
||||
# Verify correct number of users are now in honor mode
|
||||
self.assertEqual(
|
||||
len(CourseEnrollment.objects.filter(mode='honor', user_id__in=real_user_ids)),
|
||||
expected_success
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_with(
|
||||
'user: [%s] reason: [%s] %s', fake_user, 'DoesNotExist', 'User matching query does not exist.'
|
||||
)
|
||||
@@ -57,6 +57,13 @@ class UserStandingTest(TestCase):
|
||||
# since it's only possible to disable accounts from lms, we're going
|
||||
# to skip tests for cms
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_can_access_manage_account_page(self):
|
||||
response = self.admin_client.get(reverse('manage_user_standing'), {
|
||||
'user': self.admin,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_disable_account(self):
|
||||
self.assertEqual(
|
||||
|
||||
@@ -898,6 +898,7 @@ class AnonymousLookupTable(ModuleStoreTestCase):
|
||||
self.assertEqual(anonymous_id, anonymous_id_for_user(self.user, course2.id, save=False))
|
||||
|
||||
|
||||
# TODO: Clean up these tests so that they use the ProgramsDataMixin.
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@ddt.ddt
|
||||
class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
|
||||
@@ -928,8 +929,11 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
|
||||
def _create_program_data(self, data):
|
||||
"""Dry method to create testing programs data."""
|
||||
programs = {}
|
||||
_id = 0
|
||||
|
||||
for course, program_status in data:
|
||||
programs[unicode(course)] = {
|
||||
'id': _id,
|
||||
'category': self.category,
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'marketing_slug': 'fake-marketing-slug-xseries-1',
|
||||
@@ -955,6 +959,8 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
|
||||
'name': self.program_name
|
||||
}
|
||||
|
||||
_id += 1
|
||||
|
||||
return programs
|
||||
|
||||
@ddt.data(
|
||||
@@ -969,6 +975,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
|
||||
with patch('student.views.get_programs_for_dashboard') as mock_data:
|
||||
mock_data.return_value = {
|
||||
u'edx/demox/Run_1': {
|
||||
'id': 0,
|
||||
'category': self.category,
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'marketing_slug': marketing_slug,
|
||||
@@ -990,6 +997,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
|
||||
self.assertEqual(
|
||||
{
|
||||
u'edx/demox/Run_1': {
|
||||
'program_id': 0,
|
||||
'category': 'xseries',
|
||||
'course_count': len(course_codes),
|
||||
'display_name': self.program_name,
|
||||
|
||||
@@ -1346,7 +1346,7 @@ def manage_user_standing(request):
|
||||
headers = ['username', 'account_changed_by']
|
||||
rows = []
|
||||
for user in all_disabled_users:
|
||||
row = [user.username, user.standing.all()[0].changed_by]
|
||||
row = [user.username, user.standing.changed_by]
|
||||
rows.append(row)
|
||||
|
||||
context = {'headers': headers, 'rows': rows}
|
||||
@@ -2378,6 +2378,7 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali
|
||||
'course_count': len(program['course_codes']),
|
||||
'display_name': program['name'],
|
||||
'category': program.get('category'),
|
||||
'program_id': program['id'],
|
||||
'program_marketing_url': urljoin(
|
||||
settings.MKTG_URLS.get('ROOT'), 'xseries' + '/{}'
|
||||
).format(program['marketing_slug']),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
/>
|
||||
|
||||
<span id="answer_${id}"></span>
|
||||
<span class="status ${status.classname}" id="status_${id}" aria-describedby="input_${id}">
|
||||
<div class="indicator-container">
|
||||
<span class="status ${status.classname}" id="status_${id}" aria-describedby="input_${id}"></span>
|
||||
<span class="sr">${status.display_name}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -693,11 +693,17 @@ class CourseFields(object):
|
||||
teams_configuration = Dict(
|
||||
display_name=_("Teams Configuration"),
|
||||
help=_(
|
||||
"Enter configuration for the teams feature. Expects two entries: max_team_size and topics, where "
|
||||
"topics is a list of topics."
|
||||
'Specify the maximum team size and topics for teams inside the provided set of curly braces. '
|
||||
'Make sure that you enclose all of the sets of topic values within a set of square brackets, '
|
||||
'with a comma after the closing curly brace for each topic, and another comma after the '
|
||||
'closing square brackets. '
|
||||
'For example, to specify that teams should have a maximum of 5 participants and provide a list of '
|
||||
'2 topics, enter the configuration in this format: '
|
||||
'{"topics": [{"name": "Topic1Name", "description": "Topic1Description", "id": "Topic1ID"}, '
|
||||
'{"name": "Topic2Name", "description": "Topic2Description", "id": "Topic2ID"}], "max_team_size": 5}. '
|
||||
'In "id" values, the only supported special characters are underscore, hyphen, and period.'
|
||||
),
|
||||
scope=Scope.settings,
|
||||
deprecated=True, # Deprecated until the teams feature is made generally available
|
||||
)
|
||||
|
||||
enable_proctored_exams = Boolean(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
$sequence--border-color: #C8C8C8;
|
||||
$link-color: rgb(26, 161, 222);
|
||||
$link-color: rgb(26, 161, 222) !default;
|
||||
// repeated extends - needed since LMS styling was referenced
|
||||
.block-link {
|
||||
border-left: 1px solid lighten($sequence--border-color, 10%);
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
BIN
common/static/css/vendor/ova/video-js.fw.png
vendored
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
common/static/css/vendor/ova/video-js.png
vendored
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 199 B After Width: | Height: | Size: 181 B |
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 271 B |
|
Before Width: | Height: | Size: 193 B After Width: | Height: | Size: 182 B |
|
Before Width: | Height: | Size: 296 B After Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 193 B After Width: | Height: | Size: 182 B |
|
Before Width: | Height: | Size: 296 B After Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 199 B After Width: | Height: | Size: 181 B |
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 271 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 602 B |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 397 B |
|
Before Width: | Height: | Size: 933 B After Width: | Height: | Size: 892 B |
|
Before Width: | Height: | Size: 179 B After Width: | Height: | Size: 169 B |
|
Before Width: | Height: | Size: 266 B After Width: | Height: | Size: 245 B |
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 289 B |
|
Before Width: | Height: | Size: 583 B After Width: | Height: | Size: 502 B |
|
Before Width: | Height: | Size: 175 B After Width: | Height: | Size: 174 B |
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 360 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 731 B After Width: | Height: | Size: 667 B |
|
Before Width: | Height: | Size: 359 B After Width: | Height: | Size: 347 B |
|
Before Width: | Height: | Size: 714 B After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 174 B After Width: | Height: | Size: 150 B |
|
Before Width: | Height: | Size: 260 B After Width: | Height: | Size: 237 B |
|
Before Width: | Height: | Size: 259 B After Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 425 B After Width: | Height: | Size: 403 B |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 102 B |
|
Before Width: | Height: | Size: 152 B After Width: | Height: | Size: 143 B |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 511 B |
|
Before Width: | Height: | Size: 242 B After Width: | Height: | Size: 228 B |
|
Before Width: | Height: | Size: 398 B After Width: | Height: | Size: 374 B |
|
Before Width: | Height: | Size: 238 B After Width: | Height: | Size: 228 B |
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 369 B |
|
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 230 B |
|
Before Width: | Height: | Size: 405 B After Width: | Height: | Size: 360 B |
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 231 B |